Getting Started
Pagination

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

ParameterTypeDefaultDescription
limitnumber25Records per page (1–100). Above the cap returns 400.
cursorstring—The pageInfo.nextCursor from the previous response. Omitted on the first page.
includeTotalbooleanfalseWhen 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

  • totalCount is null by default. A total is expensive under cursor pagination (a filtered COUNT(*) is a full scan). Use hasMore to advance. If you genuinely need the number, send includeTotal=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 cursor is 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: offset
⚠️

These 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

EndpointAcceptsResponse
GET /paymentslimit + cursor · page deprecated{data[], total, page, limit, totalPages, nextCursor}
POST /payments/transactions:searchpage.limit + page.after in the bodyRelay envelope + pageInfo
GET /payments/splitlimit + afterRelay envelope + pageInfo
GET /payments/links, /payments/links/{id}/attempts, /payments/links/{id}/paymentslimit + cursor (plus includeTotal on the list){items} + pageInfo
GET /kyc/sessionslimit + cursor{items, nextCursor} + pageInfo
GET /wallet/operations, /end-users, /corporate-accounts, /qrlimit + cursor{operations|items, nextCursor} + pageInfo
GET /subscriptions/* (13 endpoints)limit + cursor · pageSize/pageToken deprecated{items, nextPageToken, pageSize} + pageInfo
GET /marketplace/dealerslimit + cursor · offset deprecated{items, total, nextCursor}
GET /automation/audit-events, /connector-deliveries, /trigger-eventslimit + cursor · offset deprecated{items, total, limit, offset, nextCursor}
GET /identity/live-sessionslimit + cursordata is an array + pageInfo
GET /identity/mobile-threat/decisions, /pos-threat/decisionslimit + cursordata is an array + pageInfo
GET /fraud/decision-logslimit + cursordata is an array + pageInfo
GET /automation/flows/{id}/runslimit + 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".

EndpointToday
GET /automation/shadow-runs, /approvalslimit + offset (deprecated)
GET /identity/mobile-apps, /live-sessions/top-sources, /mobile-threat/package-ioc*limit only
GET /marketplace/audit-log, /payout-ledger, /transactionslimit only
GET /ledger/payoutslimit + beforeSeq

Not paginated by design — do not wait for a cursor on these; none is coming:

EndpointWhy
GET /identity/live-sessions/{traceId}/timeline, /related-sessions, /account-linksA single trace's detail — bounded by nature
GET /sanction/intelligence/entity-searchRelevance-ranked search; "the best N matches" is the correct behaviour
GET /identity/admin/intelligence/{device,ip,identity}-profilesRisk-ranked triage list — the sort key is mutable
GET /identity/admin/bot-trust/actorsAn 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.

EndpointAccepts
GET /sanction/screening/historylimit + cursor ✅
GET /sanction/monitoring/{alerts,entities}limit + cursor + includeTotal ✅
GET /sanction/analyst/{case-queue,hit-queue}limit + cursor ✅
GET /sanction/monitoring/matcheslimit + cursor ✅
GET /sanction/blacklistlimit + cursor + counters ✅
GET /fraud/admin/{shadow-runs,outbox-events,blacklists}limit + cursor ✅
GET /fraud/admin/script-assets/{id}/execution-logslimit + cursor ✅
GET /identity/admin/bot-trust/{issuers,keys}limit — the registered issuer set is small
GET /identity/admin/bot-trust/review-eventslimit (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