Envelopes, errors, idempotency and paging

Developer·9 minutes to read

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_URL — the same variable the TypeScript and Python SDKs and botseon-task read — 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_URL/api/v1/openapi.json" -H "accept: application/json"
const res = await fetch(`${process.env.BOTSEON_API_URL}/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, an x-request-id header, an api-version header and the three RateLimit-* headers described under Rate limits. A refusal carries the same headers. GET /openapi.json is the exception: it is sent with cache-control: public, max-age=3600 and no RateLimit-* headers. 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 could not be validated.",
    "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 carries the failing paths and their messages — never a value you sent; it appears on invalid_request, and on some conflict and forbidden refusals.

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

A POST that creates something carries an Idempotency-Key header of 1 to 128 characters, including the batch endpoint. Leaving it out there is an invalid_request whose details names headers.idempotency-key. A POST whose path already names its resource — such as /runs/{id}/cancel, /approvals/{id}/decide or /groups/{id}/members — takes the header optionally, and still replays when you send one.

  • 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 and cursor. limit runs from 1 to the list's own ceiling — 200 unless the list's page in this section says otherwise, and never more than 1 000 — and defaults to 50. A limit over the ceiling is an invalid_request whose details names limit; it is never quietly clamped. The OpenAPI document gives each list's ceiling as the maximum of its limit parameter.

The response's nextCursor is the value to pass back, or null when the page is the last one. A cursor is opaque: pass it back unchanged, with the same sort, direction and filters as the request that returned it. 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. A cursor that cannot be read is an invalid_request naming cursor.

Sorting

A list that can be sorted takes sort, one of the fields its OpenAPI parameter lists, and direction, asc or desc. Leaving either out gives the list's default order, which its page names. A sort the list does not declare is an invalid_request naming sort.

A cursor carries the order it was issued under. Passing it back under another sort or direction is an invalid_request naming cursor; start again from the first page instead.

Filters

A list's filters are query parameters named on its page and in the OpenAPI document. Each takes a comma-separated list of at most 100 values, and a row matches when it matches any value of one filter and every filter given — ?state=active,paused&member=ada@example.com is "active or paused, and Ada's".

A value the filter does not accept, more than 100 values, or a query parameter the list does not declare at all is an invalid_request whose details names the parameter. A filter you mistyped never answers an empty page or the unfiltered list.

A list that takes a time window does so with from and to, each an ISO 8601 date (2026-10-01) or instant with an offset (2026-10-01T09:00:00+02:00). from must be before to, and the window may be at most as long as the list's page says; either rule broken is an invalid_request naming from or to.

Members by id or email

Wherever a path or a filter takes a member, it takes either the member's id or their email address, in any letter case: /members/3f2504e0-… and /members/Ada@Example.com name the same person. The reference is resolved only inside your own organisation.

An address that is not a member of your organisation answers the same whether or not it belongs to anyone else on the service: not_found (404) in a path, and invalid_request (400) in a filter, whose details names the filter and the value's position (member.1) and never the address.

Rate limits

Three buckets of one minute each: 300 requests per key, 1 000 per organisation, and 60 per platform key. Every response to an authenticated request — a success and a refusal alike — says where your key stands:

HeaderMeaning
RateLimit-LimitRequests the bucket admits per window.
RateLimit-RemainingRequests left in this window; it drops by one per call on the same key.
RateLimit-ResetSeconds until the window starts again.

The headers describe your key's bucket, or the organisation's bucket when that is the one that refused. The call that goes over answers rate_limited (429) with RateLimit-Remaining: 0 and 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 run starts an organisation may make in an hour — answer quota_exceeded instead, and waiting is not always the remedy. More than fifty operations in a batch is an invalid_request.

Versioning and deprecation

Every response carries api-version: 1, the major version in the path. Within /api/v1:

  • Additive changes stay in v1: a new endpoint, a new optional parameter, a new field in a response, a new value in a list your code should already treat as open. Parse responses so that a field you do not know is ignored.
  • A breaking change gets a new major path (/api/v2): removing or renaming a field, a parameter or an endpoint, tightening what a request accepts, or changing what a field means. v1 keeps answering beside it.
  • The document has its own version. The OpenAPI document's info.version is MAJOR.MINOR.PATCH. MAJOR is always the path's major (1 for /api/v1): a breaking change gets a new path, never a new major of this document. MINOR goes up with every additive change to the contract — an endpoint, a parameter, a field, a named schema, an enum value. PATCH goes up with a change that alters no request or response: a description or an example. The api-version header stays the major alone. The SDKs record the document version they were generated from, so an SDK built from 1.4.0 knows every endpoint and field 1.4.0 names.
  • A deprecated route announces itself before it goes. Its responses carry Deprecation (the date it was deprecated, RFC 9745), Sunset (the HTTP date it stops answering, RFC 8594) and, where there is one, Link: <…>; rel="successor-version" naming its replacement. The OpenAPI document marks its operation deprecated: true, and the changelog names the route and its Sunset date in the release that deprecates it.

The OpenAPI document is the contract

Every operation in the document declares its success status and schema, the error envelope on each refusal status it can answer (400, 401, 403, 404, 409, 429 and the rest, as applicable), the response headers above, and for a list its limit maximum, sort and direction values and filters. The document is generated from the schemas the routes validate against, and each endpoint has a contract test that checks a real request and response against the committed document — a field that is not in the document is not in the API.

Last verified against build c0f77aa.