Envelopes, errors, idempotency and paging

Developer·5 minutes to read

Verified against docs/superpowers/specs/2026-09-15-p4-a-typed-http-api-openapi-webhooks.md §1.2, §4.1, §4.2; re-verified against the code at close-out.

Every endpoint in this section shares the shapes below. Read this page once and the rest of the section carries no surprises.

The base URL and the OpenAPI document

Every path is relative to /api/v1 on the origin your install answers on. The samples in this section read that origin from BOTSEON_API_ORIGIN, so nothing on a page names a production host.

The machine-readable contract is served unauthenticated, cached for an hour, and is generated from the same schemas the routes validate against — a field that is not in the document is not in the API.

GET /openapi.json

<!-- generated:begin GET /openapi.json --> <!-- generated:end GET /openapi.json -->
curl -sS "$BOTSEON_API_ORIGIN/api/v1/openapi.json" -H "accept: application/json"
const res = await fetch(`${process.env.BOTSEON_API_ORIGIN}/api/v1/openapi.json`, {
  headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error(`openapi: ${res.status}`);
const doc = (await res.json()) as { openapi: string; info: { title: string; version: string } };
console.log(doc.openapi, doc.info.title, doc.info.version);

The response envelope

A successful response is application/json with cache-control: private, no-store and an x-request-id header. A list answers { "data": [ ... ], "nextCursor": null }. A single row answers the row itself, not a wrapper.

A refusal answers one shape, whatever refused it:

{
  "error": {
    "code": "invalid_request",
    "message": "The request was not accepted.",
    "requestId": "…",
    "details": [{ "path": "name", "message": "Too small: expected string to have >=1 characters" }]
  }
}

code is one of the fourteen below. message is a fixed sentence per code and nothing interpolates into it, so it is safe to show to a person but never worth parsing. requestId is the same value as the x-request-id header and is what a support request should quote. details appears on invalid_request only and carries the failing paths and their messages — never a value you sent.

Errors

StatusCodeWhat it means
400invalid_requestThe body, query or headers failed the schema.
401unauthenticatedNo credential, a credential that is not accepted, the wrong key class, or a scope the route does not admit.
402payment_requiredThe organisation needs a trial or a payment before this write.
403forbiddenThe credential is accepted and the person behind it may not do this.
404not_foundThe row is not there, or it is not yours to read.
405not_supportedThe route exists and the product has no such verb.
409conflictThe row is in a state that refuses this call.
409idempotency_in_progressAn earlier request with the same Idempotency-Key has not answered yet.
413payload_too_largeThe body is over 1 MB.
422idempotency_mismatchThe same Idempotency-Key arrived with a different body.
429quota_exceededA product ceiling is reached — a key count, a bot count, a run-start ceiling.
429rate_limitedA request bucket is full.
500internalSomething failed on the service's side; the cause is in the server log against requestId.
503unavailableThe service could not answer.

A 404 is deliberate where a 403 would be more informative: the existence of a row you may not read is not the API's to confirm. A scope mismatch answers 401 rather than 403 for the same reason — a 403 would tell a probe that the key is real.

Idempotency

Every POST carries an Idempotency-Key header, including the batch endpoint. Leaving it out on a POST is an invalid_request whose details names headers.idempotency-key.

  • The same key with the same body, twice: the second answers the stored status and body, with Idempotent-Replayed: true. Nothing is written twice.
  • The same key with a different body: idempotency_mismatch.
  • Two requests racing on one key: the loser answers idempotency_in_progress; it never waits for the winner's handler.
  • A stored refusal replays as a refusal. A request that failed with internal is not stored at all, so the same key can be retried.
  • A key is remembered for 24 hours, then reused afresh.

Mint one key per logical attempt — a UUID is the usual shape — and reuse it on every retry of that attempt.

Paging

A list takes limit (1 to 200, default 50) and cursor. The response's nextCursor is the value to pass back, or null when the page is the last one. A cursor only orders; it never widens what you can see, so a cursor taken from another organisation's list returns your own rows or none.

Rate limits

Three buckets of one minute each: 300 requests per key, 1 000 per organisation, and 60 per platform key. A refusal answers rate_limited with Retry-After: 60. Requests that fail to authenticate are counted separately, against the caller's address, so bad credentials cannot spend a working key's allowance. Product ceilings — the twenty live keys, the ten webhook endpoints, the fifty operations in a batch, the run starts an organisation may make in an hour — answer quota_exceeded instead, and waiting is not always the remedy.

Last verified against build c0f77aa.