Pagination
The platform standard is cursor-based (keyset) pagination: limit + cursor.
Endpoints are migrating to it gradually. Not all of them accept the same shape today — which endpoint accepts what is in the table below. Use limit + cursor for a new integration; the older parameters keep working, but they now come back with a deprecation header.
Request
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 25 | Records per page (1–100). Above the cap returns 400. |
cursor | string | — | The pageInfo.nextCursor from the previous response. Omitted on the first page. |
includeTotal | boolean | false | When true, pageInfo.totalCount is populated. This costs. |
curl "https://api-gateway.lunixi.com/api/v1/wallet/operations?limit=25" \
-H "Authorization: Bearer $LUNIXI_TOKEN"Response
pageInfo always sits next to data — not inside it.
{
"status": "success",
"code": "SUCCESS",
"data": [ { "id": "wop_01J8ZU..." } ],
"pageInfo": {
"hasMore": true,
"nextCursor": "eyJ2IjoxLCJ0IjoiMjAyNi0wOC0xN1QxMDowMDowMC4xMjM0NTZaIiwiaWQiOiJ3b3BfMDFKOFpVIn0",
"totalCount": null
},
"requestId": "req_01J8ZV..."
}Read pagination metadata from pageInfo, not from inside data.
Depending on the endpoint, data may be an array or an object — both occur, and that will not change. This is exactly why the pagination metadata was not put inside data: turning an array into an object would break existing integrations. pageInfo sits in the same place either way, as a sibling of data.
During the transition some endpoints also return a copy at data.pageInfo. Do not rely on it — the canonical location is the top level, and the copy may be removed later.
On an endpoint whose data is an object the same response looks like this — pageInfo is still in the same place:
{
"status": "success",
"code": "SUCCESS",
"data": { "items": [ { "id": "sub_01J8ZU..." } ] },
"pageInfo": { "hasMore": false, "nextCursor": null, "totalCount": null },
"requestId": "req_01J8ZV..."
}What you need to know
totalCountisnullby default. A total is expensive under cursor pagination (a filteredCOUNT(*)is a full scan). UsehasMoreto advance. If you genuinely need the number, sendincludeTotal=true— that request is slower and counts more heavily against your rate limit.- Pagination is forward-only. There is no
prevCursor. To navigate backwards, keep the cursors you have seen on your side. - The
cursoris opaque. Do not decode it, construct it, or edit it — the format can change without notice. Send back only the value you received in the previous response. - Ordering is always newest-first and deterministic. Even if the list keeps being written to while you page through it, no record is repeated or skipped.
- Reset the cursor if you change filters (start from the beginning). A cursor is bound to the query that produced it.
Why is the sort key always a timestamp? Cursor pagination requires the sort key to be immutable — the cursor anchors to one of its values. If the key can change afterwards (a risk score, "last seen", "last updated"), a record either shows up twice or never. So paginated lists are ordered by when a record was created; orderings like "most recently updated" get no cursor.
INVALID_CURSOR
A malformed, unrecognised or unsupported-version cursor returns 400 INVALID_CURSOR.
{
"status": "failure",
"code": "INVALID_CURSOR",
"statusCode": 400,
"message": "Invalid pagination cursor — …"
}What to do: do not construct cursors yourself; send back the pageInfo.nextCursor value from the previous response verbatim. If you changed filters, reset the cursor.
Why an error rather than silently returning the first page? A silent reset caused reconciliation loops to process the same page twice. An explicit error beats silent duplication.
Walking every page
let cursor: string | undefined;
do {
const qs = new URLSearchParams({ limit: '100', ...(cursor ? { cursor } : {}) });
const res = await fetch(
`https://api-gateway.lunixi.com/api/v1/wallet/operations?${qs}`,
{ headers: { Authorization: `Bearer ${process.env.LUNIXI_SECRET_KEY}` } },
);
const body = await res.json();
// On this endpoint `data` is an object; on others it may be an array.
// `pageInfo` is in the same place either way — next to `data`.
process(body.data.operations ?? body.data.items ?? body.data);
cursor = body.pageInfo.hasMore ? body.pageInfo.nextCursor : undefined;
} while (cursor);Legacy parameters
Some endpoints still accept the historical page, offset, pageSize, pageToken, beforeSeq and cursorCreatedAt+cursorId parameters. They keep working, but using them makes the response carry:
Deprecation: true
Sunset: Sun, 31 Jan 2027 00:00:00 GMT
X-Deprecated-Params: offsetThese headers are your only signal for planning the migration. Leave somewhere in your client to log Deprecation: true — until the Sunset date everything keeps working silently, and that date is when you would otherwise find out.
Endpoint status
The migration is in progress. Where things stand today:
Endpoints that accept a cursor
| Endpoint | Accepts | Response |
|---|---|---|
GET /payments | limit + cursor · page deprecated | {data[], total, page, limit, totalPages, nextCursor} |
POST /payments/transactions:search | page.limit + page.after in the body | Relay envelope + pageInfo |
GET /payments/split | limit + after | Relay envelope + pageInfo |
GET /payments/links, /payments/links/{id}/attempts, /payments/links/{id}/payments | limit + cursor (plus includeTotal on the list) | {items} + pageInfo |
GET /kyc/sessions | limit + cursor | {items, nextCursor} + pageInfo |
GET /wallet/operations, /end-users, /corporate-accounts, /qr | limit + cursor | {operations|items, nextCursor} + pageInfo |
GET /subscriptions/* (13 endpoints) | limit + cursor · pageSize/pageToken deprecated | {items, nextPageToken, pageSize} + pageInfo |
GET /marketplace/dealers | limit + cursor · offset deprecated | {items, total, nextCursor} |
GET /automation/audit-events, /connector-deliveries, /trigger-events | limit + cursor · offset deprecated | {items, total, limit, offset, nextCursor} |
GET /identity/live-sessions | limit + cursor | data is an array + pageInfo |
GET /identity/mobile-threat/decisions, /pos-threat/decisions | limit + cursor | data is an array + pageInfo |
GET /fraud/decision-logs | limit + cursor | data is an array + pageInfo |
GET /automation/flows/{id}/runs | limit + cursor · offset deprecated | {items, total, nextCursor} + pageInfo |
On the last two rows data is an array and will stay one — read the pagination metadata from pageInfo next to it. Turning the array into an object would break existing integrations, so it was deliberately not done.
Not paginated yet — here limit is not a page size but a cap: records beyond it are not reachable today, and the response does not say so. Think of it as "returns the newest N records".
| Endpoint | Today |
|---|---|
GET /automation/shadow-runs, /approvals | limit + offset (deprecated) |
GET /identity/mobile-apps, /live-sessions/top-sources, /mobile-threat/package-ioc* | limit only |
GET /marketplace/audit-log, /payout-ledger, /transactions | limit only |
GET /ledger/payouts | limit + beforeSeq |
Not paginated by design — do not wait for a cursor on these; none is coming:
| Endpoint | Why |
|---|---|
GET /identity/live-sessions/{traceId}/timeline, /related-sessions, /account-links | A single trace's detail — bounded by nature |
GET /sanction/intelligence/entity-search | Relevance-ranked search; "the best N matches" is the correct behaviour |
GET /identity/admin/intelligence/{device,ip,identity}-profiles | Risk-ranked triage list — the sort key is mutable |
GET /identity/admin/bot-trust/actors | An aggregate over a rolling window (30 days by default) |
Why do risk-ranked lists get no cursor? Keyset pagination requires the
sort key to be immutable — the cursor anchors to one of its values. A
risk score is recomputed on every new session: if a profile on page 1 drops
while you ask for page 2, it slides onto page 2 and you see it twice; a
profile that rises is never shown at all. On these surfaces the correct
behaviour is already "the riskiest N", not walking the whole ranking. Narrow
with scopeType, fingerprintId, deviceId, ipAddress, identityKey.
Panel / operator surface — these require an RBAC permission and are not listed in Swagger; they belong to the panel, not to an integration flow.
| Endpoint | Accepts |
|---|---|
GET /sanction/screening/history | limit + cursor ✅ |
GET /sanction/monitoring/{alerts,entities} | limit + cursor + includeTotal ✅ |
GET /sanction/analyst/{case-queue,hit-queue} | limit + cursor ✅ |
GET /sanction/monitoring/matches | limit + cursor ✅ |
GET /sanction/blacklist | limit + cursor + counters ✅ |
GET /fraud/admin/{shadow-runs,outbox-events,blacklists} | limit + cursor ✅ |
GET /fraud/admin/script-assets/{id}/execution-logs | limit + cursor ✅ |
GET /identity/admin/bot-trust/{issuers,keys} | limit — the registered issuer set is small |
GET /identity/admin/bot-trust/review-events | limit (capped at 100) |
bot-trust/review-events is an audit trail, written only by an
administrative action (an issuer/key status change) — it grows at human pace,
it is not a stream. The cap of 100 is adequate today; narrowed by issuerId,
one issuer's entire history fits in a single response. If we see an install
pressing against the cap it will get a cursor — this is not a
"never paginated by design" case.
Related
- Error Handling — the envelope and the codes
- API Overview — base URL and request format