List envelope and paging
One collection endpoint uses the same paging shape as every other collection endpoint: it returns an envelope with items and nextCursor. Read nextCursor, not items.length, to decide whether there is another page. limit defaults to 50, is clamped to 200, and cursor is opaque. If a cursor is missing, invalid, foreign, or points at a row that no longer exists, the request starts from the beginning rather than failing.
Envelope
Section titled “Envelope”Collection responses use this shape:
{ "items": [ ... ], "nextCursor": "eyJ..." }nextCursor is null on the last page. A full page can still be the last one, and an empty page does not prove there is nothing after it. For that reason, paging logic branches on the cursor value only.
Paging requests
Section titled “Paging requests”The paging pattern is:
GET /devices?tenantId=…&limit=50GET /devices?tenantId=…&limit=50&cursor=<nextCursor from the previous response>limit defaults to 50 and is capped at 200. Values outside that range are clamped, not rejected. cursor is opaque: clients do not parse it, do not construct it, and do not treat an unrecognized cursor as an error. A request with a cursor that cannot be used starts from the beginning.
Why cursor paging is used
Section titled “Why cursor paging is used”Cursor paging avoids the drift that happens with offset paging. If rows are inserted while a client is reading, later rows shift by position and a client can silently skip or repeat data. A cursor names the last row seen, so later insertions do not corrupt the sequence.
This matters most for collections that are written continuously, such as telemetry, the audit log, and voice turns.
The cursor carries a sort key and an id. The id matters because the sort key is not always unique on its own. Two rooms can share a name, and two telemetry rows can share a millisecond. Without the id tie-break, a page boundary can skip a row or repeat one forever.
Ordering by collection
Section titled “Ordering by collection”Ordering is deliberate and varies by collection:
| Collection | Order |
|---|---|
/devices | oldest first, by creation |
/tenants, /tenants/:id/clients, /rooms, /properties | by name |
| telemetry, activity, voice turns, registrations, firmware | newest first |
/devices, /tenants, /tenants/:id/clients, /rooms, and /properties keep their natural collection-specific ordering. The shared paging behavior does not force them into one universal sort.
Derived collections
Section titled “Derived collections”/devices/alerts, /devices/commissioning, and /devices/rollouts are derived from current state rather than read from a table. They still return the same envelope. That means a client does not need to know whether a collection is derived or stored.
For these collections, the cursor identifies a row by identity rather than by position. That preserves the collection’s own ordering, including severity-first ordering for alerts, instead of forcing everything into time order.
Building a paged collection endpoint
Section titled “Building a paged collection endpoint”When you add a new collection endpoint, use the shared paging helpers and fetch one more row than the caller asked for. That extra row is what lets the server distinguish “last page” from “the next page would be empty” without a second count query.
A typical implementation does three things:
- Resolve
limit. - Decode
cursor. - Query for
limit + 1rows, ordered with a stable tie-break, then convert the result into the envelope.
For published API contracts, declare limit: pageLimitQuery and cursor: pageCursorQuery so the OpenAPI document describes paging consistently.
Special case: BigInt ids
Section titled “Special case: BigInt ids”Telemetry and audit use BigInt ids, so they do not use the generic cursor comparison directly. The cursor id is validated as numeric first, and the comparison is spelled out with BigInt(cursor.key). If the cursor cannot be validated, the request behaves like a first page rather than producing a server error.
Consumers
Section titled “Consumers”The console unwraps the envelope in one place and expects collection endpoints to return it. It renders the first page only and does not yet follow nextCursor. That means large tenants can see truncated lists in the UI until paging is wired through.
httpPage() exists for the follow-up behavior, and because the envelope is unwrapped in one place, enabling it is a focused change.
home-agent follows the same pattern with getList().
Endpoints that do not use nextCursor
Section titled “Endpoints that do not use nextCursor”GET /capabilities and GET /rooms/suggestions return { items } and nothing else. That is intentional. They are fixed catalogues, not collections that grow with tenant data.
The capability catalogue is static for a build of the platform and is served with an ETag. Room suggestions are a fixed list of names. Neither needs cursor paging, because neither can grow during normal tenant use.
The rule is: if the size depends on tenant data, it pages by cursor. If the size is fixed by the platform build or by a fixed catalogue, { items } is enough.
What to check
Section titled “What to check”When you review a list endpoint, ask whether it can grow without a deploy. If the answer is yes, it needs the envelope and cursor paging. If the answer is no, it may return a bare { items }. Adding a cursor later is a breaking change for nobody; removing one is not.