Skip to main content

Paginate list endpoints

Page DALP list endpoints with JSON:API sort, page, and filter query parameters, read the response links object, and stay inside the 200-item page-size cap.

Overview

Most v2 collection endpoints return an offset-and-limit page with links to neighbouring pages. Page one with page[offset], page[limit], sort and filter[<field>], and follow links.next until it is null. Those endpoints refuse the flat keys limit, offset, sortBy, sortDirection and filters with a 400. Other endpoints declare their own flat limit, with offset or cursor, or stream events instead: read the route schema, and see other pagination shapes.

Offset-based responses contain data, meta.total, and navigation links. This request is illustrative. Replace the host and API key with your deployment values.

curl --globoff "https://your-instance.example.com/api/v2/tokens?sort=-createdAt&page[offset]=0&page[limit]=50" \
  -H "X-Api-Key: sm_dalp_..."

Read the response

The response to that request looks like this. The example leaves out the 50 rows in data:

{
  "data": [],
  "meta": { "total": 214 },
  "links": {
    "self": "/api/v2/tokens?sort=-createdAt&page%5Boffset%5D=0&page%5Blimit%5D=50",
    "first": "/api/v2/tokens?sort=-createdAt&page%5Boffset%5D=0&page%5Blimit%5D=50",
    "prev": null,
    "next": "/api/v2/tokens?sort=-createdAt&page%5Boffset%5D=50&page%5Blimit%5D=50",
    "last": "/api/v2/tokens?sort=-createdAt&page%5Boffset%5D=200&page%5Blimit%5D=50"
  }
}

The platform percent-encodes the brackets in each link, so page[offset] reads page%5Boffset%5D. Follow a link as it is rather than matching its text.

Follow links.next and links.prev instead of computing the next offset by hand. Each link already carries the active sort and filters, so following them retains the query parameters from that response. Changing filters starts a different walk.

links.next follows one rule: it is present exactly when page[offset] + page[limit] is less than meta.total, and null on exactly the last page. That makes it the only correct end-of-walk signal. A short page or an empty data array is not a substitute: data.length and links.next come from separate parts of the response, and treating data.length < page[limit] as your end-of-walk check is a bug that a page walk under concurrent writes eventually triggers, as A page walk is not a consistent read describes. Stop when links.next is null, never before.

Sort

Send sort with a field name to sort ascending, or a field name prefixed with - to sort descending. DALP accepts exactly one sort field per request, and a comma-separated list of fields is not a supported sort. Each list endpoint documents its own sortable fields on its spec-rendered page. A list can refuse a field it does not publish with a 400, or ignore it and answer in its default order. The token list refuses with a 400 TOKEN_LIST_FILTER_INVALID (DALP-9289), and data.filterId carries the refused term as you sent it. The same code answers a fields[token] member the list does not publish. When a list ignores the field, the links in the response still repeat the sort you sent, so send only fields the route's reference lists as sortable.

Each collection defines its own default sort, and they differ: some order by newest first, others by a name or a display field ascending. Do not assume -createdAt. If order matters to your integration, send sort explicitly rather than relying on the default, and read the generated contract for the endpoint you are calling.

Deterministic ordering comes from tiebreaker columns the route appends to the field you sorted by, not from the sort field alone. A createdAt sort orders by createdAt first and then by a stable secondary column the route itself declares, so two rows sharing an identical createdAt value still resolve to one deterministic order across repeated identical queries. That guarantee is about tie-breaking within one fixed data state. It says nothing about two requests separated by an insert or a delete.

Page window

page[offset] is the zero-based index of the first item to return. page[limit] is the page size. Omit either to get the default: offset 0, limit 50. page[limit] accepts 1 through 200. To read more than 200 items, follow links.next.

page[offset] has no upper bound, only a lower one: it must be an integer of zero or greater. A negative or fractional offset, and a page[limit] outside 1 through 200, fail with a 400 REQUEST_INVALID (DALP-14261) whose data.errors names the parameter. Neither refusal clamps the value for you or returns an empty page, so fix the request and resend.

An offset makes the database count past every skipped row, so a page deep in a large collection costs more than the first page. To read a collection of any size at a steady cost per page, use the cursor described next.

Read deep pages with a cursor

A list endpoint with cursor support returns meta.nextCursor: an opaque string that marks the position after the last row of the page, or null on the last page. Send it back as page[cursor] together with the same sort and filters to read the next page. The database seeks straight to that position, so page 1 and page 50,000 cost about the same. Among the lists that support it are GET /api/v2/webhooks, GET /api/v2/webhooks/{webhookId}/deliveries, GET /api/v2/webhook-receipts, GET /api/v2/operations/cases, GET /api/v2/users/me/events, GET /api/v2/tokens/{assetId}/transfers and the reporting statement, confirmation and transaction-report lists. A list that supports it declares page[cursor] in its route schema.

curl --globoff "https://your-instance.example.com/api/v2/webhook-receipts?sort=-receivedAt&page[limit]=200&page[cursor]=eyJ..." \
  -H "X-Api-Key: sm_dalp_..."

On a cursor walk, links.self repeats the cursor you sent and links.next carries meta.nextCursor, so following links.next keeps the walk on the cursor. links.prev is null, because a cursor reads forward only.

Treat the cursor as a value to pass back unchanged, not one to build or read. It is bound to the sort and filters of the request that returned it. A cursor sent with a different sort, different filters or a different search term, or one that has been altered, fails with a 400, COLLECTION_PAGE_CURSOR_MISMATCH (DALP-9565). Every list that declares page[cursor] lists this code among its 400 responses. The cursor only names a position: it never widens what your key may read. When page[cursor] is present, page[offset] is ignored. Stop when meta.nextCursor is null.

A page walk is not a consistent read

DALP takes no snapshot when you start paging. Every request re-runs the query live against current state, meta.total included, so a page and the total it came with can both shift while a caller is still walking. A row can move between offsets, drop off the page it was on, or appear twice across two pages, purely because a write landed between two of your requests. Nothing in the response marks that it happened.

For a display list refreshed on a schedule, live re-query is what you want: later pages reflect the latest writes. A reconciliation job or an export needs the opposite guarantee, accounting for every row exactly once, and live re-query alone cannot give it that. For that case, page inside a filter range that does not admit new rows while you walk, such as a fixed creation-time window: on GET /api/v2/tokens, send filter[createdAt][lt] set to the time the walk started. A fixed window reduces drift but does not stop rows from being updated or deleted. Deduplicate by resource id and reconcile against a later read when completeness matters. A cursor keeps your place in the sort order, so a row whose sort value does not change during the walk is returned once. A row whose sort value changes mid-walk can be skipped or returned twice, and rows written or deleted during the walk can appear or vanish.

meta.total inherits the same caveat: it is recomputed on every single request as a live count on the same query, not carried over from the first page. A walk that needs a defensible row count for reconciliation must reconcile the returned resource identifiers. Recording the first total provides context but cannot prove that the walk saw every row.

Filter

filter[<field>] filters on a field the endpoint declares as filterable. The shorthand filter[status]=active applies the field's default operator, usually equality. Name an explicit operator when you need one: filter[status][inArray]=active,paused matches either value.

Filtering supports these operators. eq, ne, gt, gte, lt, and lte cover equality and ordering. iLike and notILike cover case-insensitive substring matching. inArray and notInArray cover membership. between covers an inclusive two-value range. isEmpty and isNotEmpty take no value at all. inArray and notInArray take 1 to 100 members, and a blank member is refused. A longer list fails with a 400 REQUEST_INVALID (DALP-14261) rather than being silently truncated. The same code answers a field the endpoint does not declare and an operator the field does not support, rather than ignoring them, and data.errors names the key. A value outside the options of an enum-backed field fails with a 400 COLLECTION_FILTER_VALUE_INVALID (DALP-9355), because dropping the filter would answer a wider page than you asked for.

When an endpoint enables global search, filter[q]=<term> searches across that endpoint's default text fields.

No flat aliases

The collection parameters are sort, page[offset], page[limit], page[cursor], filter[<field>] and, on a list that declares it, fields[<type>]. A list that takes these parameters refuses the flat keys sortBy, sortDirection, limit, offset, and filters with a 400 REQUEST_INVALID that names the key and the parameter to send instead. A list that declares its own flat limit, with offset or cursor, accepts the keys it declares, as other pagination shapes describes. DALP refuses them rather than ignoring them, because an ignored limit or filters would return the unfiltered first page and look like a correct answer.

Other pagination shapes in the API

This offset and limit contract covers the list endpoints that take page[...]. Other lists in the Platform API page another way, so read the route's schema rather than assuming this one.

The request-log list, GET /api/v2/monitoring/api/request-logs, and the health-snapshot list, GET /api/v2/blockchain-monitoring/health-snapshots, page with top-level cursor and limit parameters instead of page[...]: send the previous response's nextCursor back as cursor, and stop when nextCursor comes back null. Their envelope is items, nextCursor, totalCount and facets, not data, links and meta.total.

Other lists declare a flat limit, with offset or cursor, as their own query parameters and take no page[...] parameter. Among them are GET /api/v2/transaction-requests, the trading venue lists, the kept custody operations lists under /api/v2/custody-ops and the reporting trail at GET /api/v2/reporting/trail. Send the keys the route's schema declares, and read the envelope from the same schema, because it differs between these routes: GET /api/v2/transaction-requests answers data, nextCursor and total, and GET /api/v2/custody-ops/reconciliation/breaks answers data, links and meta.

Event streams, such as the request-log and health-snapshot SSE feeds, carry no pagination contract at all. Connecting starts a live feed from that point forward. It does not replay history and gives no resume cursor, so a dropped connection loses whatever it missed. Use the matching list endpoint for a historical page, and the stream only for events while it stays connected.

Next steps

  • Errors covers what an out-of-range page size or an unknown filter field returns.
  • Each resource's spec-rendered page in the API reference lists that endpoint's sortable and filterable fields.

Was this page helpful?