List envelope and paging
Collection endpoints in ALYT return the same paging shape, so a client that can read one collection can read the rest. The shared response envelope is { "items": [ ... ], "nextCursor": "eyJ..." }, and nextCursor is null on the last page. Clients branch on nextCursor, not on items.length, because a full page can still be the last page and an empty page does not prove there is nothing after it.
The response envelope
Section titled “The response envelope”Every collection endpoint answers with the same envelope when it grows with tenant data:
{ "items": [ ... ], "nextCursor": "eyJ..." }The meaning is the same everywhere:
itemscontains the rows for the current page.nextCursorpoints to the next page.nextCursor: nullmeans there is no next page.
Do not use items.length to decide whether there is more data. A page can be full and still be the last one.
Paging requests
Section titled “Paging requests”Paging uses limit and cursor query parameters:
GET /devices?tenantId=…&limit=50GET /devices?tenantId=…&limit=50&cursor=<nextCursor from the previous response>Rules for these parameters:
limitdefaults to50.limitis capped at200.- Values outside that range are clamped, not rejected.
cursoris opaque. Do not parse it or build one.- A cursor that was not issued by ALYT, or one whose row no longer exists, is treated as if paging starts from the beginning. It is not an error.
This makes paging safe for callers. The API does not expect clients to understand cursor contents.
Why cursors are used
Section titled “Why cursors are used”Paging by offset is fragile because inserts shift rows by position. If a device is registered while someone is paging a fleet, later rows move down and one can be skipped. A cursor avoids that by naming the last row seen, so new rows do not corrupt the sequence.
This matters most where paging matters most: telemetry, the audit log, and voice turns are all written continuously.
A cursor includes a sort key and an id. The id matters because sort keys are not always unique. Two rooms can share a name, and two telemetry rows can share the same millisecond. Without the 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 depends on the collection.
| Collection | Order |
|---|---|
/devices | oldest first, by creation |
/tenants, /tenants/:id/clients, /rooms, /properties | by name |
| telemetry, activity, voice turns, registrations, firmware | newest first |
The paging helper supports that by taking a direction and a key type, so the collection keeps its own order instead of being forced into one global order.
Derived collections
Section titled “Derived collections”/devices/alerts, /devices/commissioning, and /devices/rollouts are derived from current state rather than read directly from a table. They still return the same envelope.
That matters because a client must not need to know whether a collection is derived. These collections page in memory, and the cursor locates a row by identity rather than by position. That lets alerts keep their severity-first ordering instead of being forced into time order for the sake of paging.
Implementing a new collection endpoint
Section titled “Implementing a new collection endpoint”When you add a new collection endpoint, it should follow the same pattern:
- Resolve
limitwith the shared paging helper. - Decode
cursorwith the shared paging helper. - Fetch
limit + 1rows. - Return the page envelope.
- Declare the query parameters in the contract so the published OpenAPI document shows them.
Fetching limit + 1 is required. The extra row tells the API whether there is another page without a second count query. That is what makes nextCursor reliable.
Fixed catalogues that do not page
Section titled “Fixed catalogues that do not page”Two endpoints return { items } and do not include nextCursor on purpose:
GET /capabilitiesGET /rooms/suggestions
These are fixed catalogues, not collections. The capability catalogue is static for a platform build and is served with an ETag. The room suggestions are a fixed list of names. Neither grows with tenant data, so there is nothing to page through.
The rule is not “does it return a list”. The rule is “can this grow without a deploy”. If the answer is yes, it pages by cursor. If the answer is no, a bare { items } is acceptable.
What consumers should expect
Section titled “What consumers should expect”The console unwraps the envelope in one place and fails loudly if an endpoint does not return it, so a migrated service does not look like an empty screen. home-agent does the same.
At present, the console renders the first page only and does not follow nextCursor yet. That means large tenants can see truncated lists in the UI. httpPage() is available for the later follow-up, and because the envelope is unwrapped in one place, that change is small when it lands.
DEV and LIVE keys, and local-first behavior
Section titled “DEV and LIVE keys, and local-first behavior”A DEV key works in a sandbox. A LIVE key works only for its own connector. Keep that in mind when testing list behavior, because the API surface and paging rules are the same, but the data scope is not.
ALYT is local-first, so the house keeps working when the cloud is down. A household sees less than staff do, but the paging contract stays the same in both cases: collections use the envelope, cursors are opaque, and clients follow nextCursor to reach the next page.