This site is being rebuilt and some pages are out of date. For current details, write to reach@inorbit.hr. This notice goes away when the rebuild is done.

No analytics unless you allow it, no tracking. This site keeps in your browser the language you pick, the theme, its colour, which site you chose, the currency on the pricing page and that you closed this notice; signing in adds session cookies. The legal page has the details.

Sign in

← Back to Platform

RFC 0033decided2026-10-03

The response contract

One way every list pages, every answer names its request, every 429 says when, and every creating write can be retried safely.

Problem

An engineer reading our API reference meets three ways to page. The audit log takes page_token with limit and an after id, deliveries take page_token with page_size, the radar takes limit with a week and offers no way to continue, and endpoints, inboxes, event types and unit categories return everything they have. Two answers carry next_page_token; none says whether there is more, none carries a total, and the token is sometimes a raw id a client could be tempted to build. Behind the public routes the whole platform holds about eighty-five list calls in eight shapes (opaque tokens, raw ids, a cursor, offsets, a bare limit that truncates, no parameters at all) with three different token encodings and no shared code.

The same engineer gets no request id back, so a support question cannot name the call; a list answer does not link to its next page; a rate-limited call from the edge gives no count or window; two of our daily bounds promise a retry hint in the docs and do not send one; and a create that times out on the network leaves the engineer guessing whether to send it again.

Every consumer we own copes on its own: three hand-made pagers in the console, a build step on the site that pages the radar by week, a command line that loops over one route, a documentation playground that follows no token, an SDK design that names a cursor field no route has. They do not talk to each other because the API never told them how.

Proposal

One contract, decided here, enforced by a test that walks every service's descriptors at build time, so a route that departs from it fails before it ships. It applies to every public route first and to every route of the platform after that.

Lists

A list is an RPC named List…. Its request takes page_size (0 asks for the route's default; more than the route's maximum is the maximum; both numbers stand in the route's documentation) and page_token, an opaque, URL-safe string copied from the previous answer and never built by a client. A token given with any other argument changed since the previous page is refused as a bad request naming page_token. Filters are named fields from one vocabulary: status, types, from and to as dates, account_id, query for text. A route with a second order has order_by, with its accepted values enumerated; there are no filter expressions.

Its answer carries the items under the resource's plural name (the first field on a new route; the first list on an older one that answered something before it), then next_page_token, empty on the last page and always present, then total_size only where the store counts cheaply and exactly; a total is never estimated. The default order is newest first unless the route says otherwise, and a page may be shorter than asked without being the last.

A capped list that is not a page, such as the twenty most frequent errors in a statistics answer, says so and has no page fields. A report that carries rows for a period is not a list.

Existing routes change additively, as the version promise requires: limit stays accepted where it exists as a deprecated alias of page_size; the audit log's after stays as the catch-up mode webhooks rely on; the radar's week filter stays and the route gains the token. Every token becomes opaque, written and read by one shared piece of code.

A diagram is drawn here in the RFCs product; this page does not show diagrams yet.

Every answer

Every response carries the request's id in x-request-id; the error envelope repeats it as request_id, so a question to support names one call. A list answer carries a Link header to its next page, built by the gateway from next_page_token, so a client that follows links needs no knowledge of our fields. A call refused at the edge for its rate carries the limit, what remains and when the window resets, and every refusal for rate or quota says when to retry. Identifiers stay prefixed and opaque, timestamps RFC 3339 in UTC, 64-bit integers decimal strings; this document restates the rules in one place.

Writes that can be retried

Every public call that creates or triggers something accepts an Idempotency-Key: a string of up to 255 bytes, a UUID by recommendation, scoped to the caller: the token's subject, so one caller's key never meets another's. The first call with a key runs. A repeat with the same key and the same payload within a day answers what the first did, marked as replayed. The same key while the first is still running is a conflict. The same key with a different payload is refused as unprocessable, a new code added beside the existing ones. No route requires a key, so a client without one behaves exactly as today.

A diagram is drawn here in the RFCs product; this page does not show diagrams yet.

Enforcement and spread

A test reads every service's contract and fails the build when a public list lacks the fields, has them in another shape, names a filter outside the vocabulary, or keeps a limit that is not marked deprecated. Today's departures are listed in the test by name and removed as each one is fixed; a new route cannot join the list. The gateway's OpenAPI document marks every list with its items and token fields, so generated SDKs can page by themselves and the command line can walk every page with one flag. The console gets one pager for every table. The documentation gains a page on paging, retries and request ids, and its playground offers the previous answer's token as the next page.

Alternatives considered

A list envelope added by the gateway ({"data": [...], "has_more": true} around every answer, in the style of a payments API). One shape for every list without touching a service, but a second shape for everything that is not a list, a gateway that must know which field is the list, and an answer that no longer matches the message the WebSocket, MQTT and the agent tools carry. The messages are the contract; the gateway adds headers, not bodies.

Offsets. offset and limit are familiar and let a client jump to page twelve. They also skip or repeat rows when the list changes under the reader and cost the store a scan per page. Keyset positions behind an opaque token are stable and cheap, and a client that needs a page number can keep a stack of tokens, as the console does.

Filter expressions (filter="status = failed AND created_at > ..."). Powerful and hard: a grammar to parse, to document, to secure and to index for. Named fields do what our routes need and every one of them is typed.

Totals on every list. Engineers building tables ask for a count. A count on every page is a second query, and on an unbounded list it is a query that grows forever; a guessed count is worse than none. The total appears exactly where it is cheap and exact.

Idempotency in the gateway. One implementation for every write, but the gateway would hold answers for every service and could not know when a write's effect is complete. The key is forwarded, and each writing service keeps its own answers.

Decision

Decided: the contract above, on every route. Every list on the platform pages with page_size and an opaque page_token, every answer names its request, every refusal for rate or quota says when to retry, and every public create or trigger can be retried with an Idempotency-Key. The test that reads every service's contract holds new routes to it, and its list of departures is empty.

Publication

The developer docs gain a page on paging, request ids, rate-limit headers and retried writes; every list route's reference shows its defaults and maximum; the changelog carries the entry; the console, the command line and the SDKs page one way.

Status log

  • 2026-10-03: opened. The first change lands the contract text, the shared paging code and the test with today's departures named.
  • 2026-10-03: every answer carries its request id, the error envelope repeats it, a list links to its next page, the edge adds rate-limit headers, and the OpenAPI document marks every list for generated clients.
  • 2026-10-03: retried writes. Every public create or trigger takes an Idempotency-Key, scoped to the caller rather than the account (simpler to reason about, and a key is a client's own); an answer that carries a secret is kept encrypted. A key reused for another request is the new 422 unprocessable.
  • 2026-10-03: the eight public lists that predated the contract page its way; the test's list of departures is empty. Tokens are bound to the arguments they came from. A spent daily allowance answers with when it resets.
  • 2026-10-04: the document says what the SDKs used to patch in: which fields an answer always carries (required, held to the serialiser by a test), how error details are told apart, each error code's HTTP status, and the API's address in every document that leaves the gateway.
  • 2026-10-04: every internal list pages the same way, finance's nineteen the last of them. A list's older limit, offset and search names keep working for the callers that send them, marked deprecated. A page token now holds a digest of the arguments it was bound to, never the arguments: a search text or a person's id never travels in a URL inside one.
  • 2026-10-04: every consumer pages one way. The finance pages and the console's admin screens ask with page_size and follow the token; iohr api --all walks every page of a list into one answer; the OpenAPI document marks the older names deprecated. Two questions the SDKs raised are settled without a wire change: schema names stay the protobuf names and each SDK shortens them, and a timestamp that was never set stays "", which every SDK reads as no value. Checked through the public edge: a list links to its next page and carries its request id and rate-limit headers, a token from another list is refused, and a missing token or an unknown route answers the same JSON envelope as every other error.
  • 2026-10-04: decided. Every proposal above is built and live; the owner closed it.
  • 2026-10-05: a person's side of the same refusals. When refuses on its own (a role that may not see the page, no route, its rate limit, a backend that does not answer) and the request takes text/html, the answer is a page in the person's language with Io, what happened and the next step, instead of 's bare text. The API host and /v1/ paths are matched first and keep this contract's JSON envelope; a test reads the mappers in order and fails if a page could ever answer the API. Access is unchanged: only the body of a refusal is.
  • 2026-10-07: Checked: the contract's PRs (#218 to #244, #339) are in every running deployment. Status unchanged.

← Back to Platform