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
| Status | Code | What it means |
|---|---|---|
| 400 | invalid_request | The body, query or headers failed the schema. |
| 401 | unauthenticated | No credential, a credential that is not accepted, the wrong key class, or a scope the route does not admit. |
| 402 | payment_required | The organisation needs a trial or a payment before this write. |
| 403 | forbidden | The credential is accepted and the person behind it may not do this. |
| 404 | not_found | The row is not there, or it is not yours to read. |
| 405 | not_supported | The route exists and the product has no such verb. |
| 409 | conflict | The row is in a state that refuses this call. |
| 409 | idempotency_in_progress | An earlier request with the same Idempotency-Key has not answered yet. |
| 413 | payload_too_large | The body is over 1 MB. |
| 422 | idempotency_mismatch | The same Idempotency-Key arrived with a different body. |
| 429 | quota_exceeded | A product ceiling is reached — a key count, a bot count, a run-start ceiling. |
| 429 | rate_limited | A request bucket is full. |
| 500 | internal | Something failed on the service's side; the cause is in the server log against requestId. |
| 503 | unavailable | The 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
internalis 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.