Pagination and filtering
Every list your app publishes should page, and every list that pages should say so in its contract. This page is about the gap between those two sentences, which is where the single most common app bug lives.
The envelope
A paged list answers with the rows, the paging state, and an echo of the applied filter:
{
"items": [ /* rows */ ],
"page": {
"limit": 50,
"offset": 0,
"total": 128,
"returned": 50,
"hasMore": true
},
"filter": {
"status": "in_stock"
}
}
| Field | Is |
|---|---|
items | The rows for this page. |
page.limit | The page size actually used — the clamped value, not necessarily what you asked for. |
page.offset | The offset actually used. |
page.total | How many rows match the filter in total. |
page.returned | How many rows are in items. |
page.hasMore | Whether another page exists. |
filter | The filter the server applied. |
Two of those are worth using rather than skimming past.
hasMore is the loop condition. Prefer it to comparing returned against limit, and prefer both to computing offset + limit < total — a total can move underneath you between pages.
filter is the debugging tool. It is what the server applied, not what you sent. A misspelled filter parameter is silently ignored by a list route, so the echo is how you discover that ?statu=in_stock did nothing. If a list returns more rows than you expected, read the echo before you read your code.
Declaring it
The gateway validates and forwards the parameters your capability declares, and only those.
{
"type": "define",
"capability": "serials.list",
"version": "1.0.0",
"summary": "List device serials",
"route": { "method": "GET", "path": "/serials" },
"parameters": [
{ "name": "limit", "in": "query",
"schema": { "type": "integer", "minimum": 1, "maximum": 200, "example": 50 },
"description": "Page size (default 50, max 200). A larger value is clamped rather than refused." },
{ "name": "offset", "in": "query",
"schema": { "type": "integer", "minimum": 0 },
"description": "Row offset for pagination (default 0)." },
{ "name": "order", "in": "query",
"schema": { "type": "string" },
"description": "Sort as 'column.asc' | 'column.desc', e.g. 'created_at.desc'." },
{ "name": "status", "in": "query",
"schema": { "type": "string", "enum": ["in_stock", "sold", "in_service"] },
"description": "Only serials in this status." }
],
"response": { "title": "SerialPage", "type": "object" }
}
An undeclared parameter strands callers on page one
This is the bug, stated plainly: if your list capability does not declare limit and offset, the gateway does not forward them. Your handler receives no offset, defaults it to 0, and answers page one. Every time. To every caller.
Nothing errors. The response is a valid 200 with a valid envelope. A partner writes the obvious paging loop, gets the same fifty rows forever, and concludes your app is broken — which it is, in the contract rather than in the code.
The same applies to every filter. A status parameter your handler happily reads is invisible to callers until it is declared, and the request that sends it gets an unfiltered list back.
So the rule for a paged list is: declare limit, offset, order, and every filter you intend anybody to use. And the counter-rule, equally worth keeping: declare them only on lists. A get, create, update or delete has no pagination, and declaring some anyway publishes a parameter that does nothing.
If a caller reports being stuck on page one, check the contract first:
curl -s https://api.revenexx.com/v1/openapi.json \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
| grep -A3 '"offset"'
Paging through everything
async function* allSerials(headers, limit = 200) {
for (let offset = 0; ; offset += limit) {
const res = await fetch(`https://api.revenexx.com/v1/serials?limit=${limit}&offset=${offset}&order=id.asc`, { headers });
const { items, page } = await res.json();
yield* items;
if (!page.hasMore) return;
}
}
Always send an order. Offset paging over an unordered result is not stable: without a deterministic sort, a row can appear on two pages or on none, and the bug shows up only under concurrent writes. Order by something monotonic and unique — id.asc, or created_at.asc on a table where that is unique enough.
Filtering
Filters on a mountCrud list are per column and exact:
curl "https://api.revenexx.com/v1/serials?status=in_stock&product_id=$PRODUCT_ID&limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
- A
?column=valuepair is equality on that column. - Several pairs are
ANDed. - One filter per column — there is no
OR, no negation, and no range expressed as two entries on the same column. - An unknown parameter is ignored rather than rejected.
That last point is why the filter echo exists, and it is also the reason to publish an enum in a filter's schema where the column has one: the API Explorer then offers the valid values instead of letting a caller guess.
When a caller needs a slice these shapes cannot express — a date range, an either-or, a computed threshold — the answer is a capability with its own parameters and its own logic, not a cleverer query string. See The typed client for what the underlying query surface can and cannot do.
Consistency with the rest of the gateway
limit, offset and order are the platform-wide list conventions, documented in API usage. Following them means a partner who has already integrated one revenexx endpoint does not have to learn yours. Inventing page=2&per_page=25 means they do.
Next steps
- CRUD — where the envelope comes from.
- Capability reference — the
parametersfield. - The typed client — the query surface behind the filters.
- API usage: pagination — the platform-wide conventions.