Admin API pagination
Driftstack admin list endpoints use endpoint-specific pagination.
High-volume lists use opaque cursors; status subscribers use
limit plus offset. Check the current
endpoint matrix below instead of assuming one universal envelope.
Why crypto orders use cursors, not offsets
Offset pagination (?page=2) is convenient but breaks
when rows are inserted or removed between page requests:
callers can skip rows or see duplicates. The crypto-order list
orders rows by created_at DESC with
order_id as the tiebreaker; new orders arrive at
the front of the list constantly. A cursor anchored to the
last seen (created_at, order_id) pair gives a
stable walk across the list even while new rows are being
added.
The crypto-order contract
GET /v1/admin/crypto-orders returns:
{
"orders": [ ...up to `limit` rows... ],
"next_cursor": "eyJ0cyI6MTcwMDAwMDAwMCwiaWQiOiJvcmRfYWJjIn0"
} orderscontains up tolimitrows from the page (defaultlimit=50; max200on this endpoint).next_cursoris a string when at least one more row exists beyond the page;nullwhen the page reaches the end of the list.
The walk loop
let cursor = null;
const all = [];
do {{
const params = new URLSearchParams({{ limit: '50' }});
if (cursor) params.set('cursor', cursor);
const res = await fetch(`/v1/admin/crypto-orders?${params}`, {{
headers: {{ authorization: `Bearer $ADMIN_KEY` }}
}});
const body = await res.json();
all.push(...body.orders);
cursor = body.next_cursor;
}} while (cursor !== null);
Stop when next_cursor is null. Do not
try to parse the cursor — its internal shape is not part of
the contract and may change between releases. Treat it as
opaque bytes.
Combining with filters
Pagination composes with the existing filter parameters
(status, search,
account_id). Send the same filter values on every
page request; the server applies them before walking past the
cursor anchor. Changing a filter mid-walk is undefined —
always start a fresh walk (drop the cursor) when filters
change.
Cursor lifetime
Cursors are not signed and do not expire on a timer; the
server treats them as the literal
(created_at, order_id) pair to seek past. A
cursor remains useful as long as the row it anchors to is
still in the scan window (default 1000 rows on filtered
queries, 51 rows on the unfiltered first page — the
limit + 1 overflow probe).
If you walk slowly enough that the anchor row falls out of
the window, the server returns an empty page with
next_cursor: null. The caller should treat that
as "end of walk" and restart from the front if a full scan is
still needed.
Validation errors
- A cursor longer than 512 characters returns
400 Bad Request. - A malformed cursor (not valid base64url JSON of
{ts, id}) returns an empty page withnext_cursor: null; the server prefers a benign empty result over surfacing decode internals.
Current endpoint matrix
These list routes currently accept cursor and return
next_cursor:
GET /v1/admin/accountsGET /v1/admin/sessionsGET /v1/admin/api-keysGET /v1/admin/audit-logGET /v1/admin/crypto-ordersGET /v1/admin/webhook-dlqGET /v1/admin/rate-limit-overridesGET /v1/admin/incidents
GET /v1/admin/status-subscribers is the current
exception: it accepts limit and offset
and returns the rows in data, without a
next_cursor field.
Cursor encoding, ordering, limits, filters, and collection-field
names are route-specific. The (created_at, order_id),
orders, default limit=50, and max
200 details above apply specifically to crypto orders.