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
OpenAPIdocument 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 new422 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,offsetand 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_sizeand follow the token;iohr api --allwalks every page of a list into one answer; theOpenAPIdocument 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.