> ## Documentation Index
> Fetch the complete documentation index at: https://developers.bloobank.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pagination

> Cursor-based pagination via opaque page tokens — when to paginate and how to think about it.

Every list endpoint in the BlooBank API uses **cursor-based pagination** with opaque tokens. This page gives the mental model; for the exact request/response contract, see the [Pagination reference](/api-reference/conventions/pagination).

## The mental model

Think of pagination as a **cursor over a stable result set**:

1. Your first request defines the cursor by supplying `filter`, `order_by`, and `page_size`.
2. The server returns a page of items plus a `nextPageToken` — a sealed envelope describing where the cursor stopped.
3. On the next call, you hand that envelope back as `page_token`. The server resumes the cursor.
4. When the server runs out of items, `nextPageToken` is `null`. You stop.

The token is **opaque** to you. Do not parse it, log it, or persist it next to business state. It carries:

* The pagination protocol version
* An expiration timestamp (typically 48 hours)
* A fingerprint of `filter` + `order_by` (so you cannot mix-and-match across iterations)
* The cursor position

This is the same model used by Stripe, GCP, and the AWS SDK list operations — chosen over offset-based pagination because it is robust to inserts and deletes between pages.

## Why not offset-based?

Offset-based pagination (`?offset=100&limit=20`) is intuitive but breaks at scale:

| Problem                    | Effect                                                                                                    |
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
| **Drift on insert**        | A new row inserted between page 1 and page 2 shifts every subsequent page, causing items to appear twice. |
| **Drift on delete**        | A row deleted between pages causes items to be skipped.                                                   |
| **Cost grows with offset** | The database must scan `offset` rows to skip them — fast for the first page, slow for page 1000.          |

Cursor-based pagination avoids all three. Pages are anchored to specific items, not positions.

## When to paginate

| Use case                      | `page_size` recommendation |
| ----------------------------- | -------------------------- |
| Interactive UI (table view)   | 10–25                      |
| Background reconciliation job | 50–100                     |
| Full-collection export        | 100 (the maximum)          |

The server's maximum is `100`. Exceeding it does not error — the value is silently coerced down.

## The shape of a list response

Every list endpoint returns this envelope:

```json theme={"dark"}
{
  "items": [ /* ... */ ],
  "nextPageToken": "spct1.eyJ2IjoxLCJleHAiOjE3MzY1NTM2MDB9.aBcDeFgHiJkLmNoPqRsTuVwXyZ"
}
```

When the collection is exhausted, `nextPageToken` is **explicitly `null`** (not omitted, not empty string). That is your single signal for end-of-iteration.

## The "iterate until null" pattern

```ts theme={"dark"}
async function* iterateAllOrders(wallet: string, filter?: string) {
  let pageToken: string | null = null;
  do {
    const params = new URLSearchParams();
    if (filter) params.set('filter', filter);
    if (pageToken) params.set('page_token', pageToken);
    params.set('page_size', '100');

    const resp = await api.get(`/wallets/${wallet}/paymentOrders?${params}`);
    for (const item of resp.items) yield item;
    pageToken = resp.nextPageToken;
  } while (pageToken !== null);
}

for await (const order of iterateAllOrders('production-main', 'status=SUCCESS')) {
  // process order
}
```

The pattern: **iterate, yield, advance, stop when token is `null`**.

## What you must keep stable across pages

Across calls within a single iteration, you **must** keep these identical:

* `filter`
* `order_by`
* The endpoint URL

Changing any of them with a token from a previous query returns `INVALID_PAGE_TOKEN` (HTTP 400). The token is bound to the query context.

You **may** change:

* `page_size` (the server honors the new value)

## When tokens expire

Tokens live \~48 hours. If a user pauses an iteration and resumes a day later, you might still be within the window. If they resume a week later, the token will return `INVALID_PAGE_TOKEN`.

Handle this gracefully:

```ts theme={"dark"}
try {
  const resp = await api.get(`/wallets/${wallet}/paymentOrders?page_token=${pageToken}`);
} catch (err) {
  if (err.status === 400 && err.reason === 'INVALID_PAGE_TOKEN') {
    // Restart pagination from the beginning
    pageToken = null;
    continue;
  }
  throw err;
}
```

Restarting is acceptable — your `filter` and `order_by` are unchanged, so you will re-traverse from the start. For idempotent consumers (using `idempotencyKey` or upserts), re-traversal is harmless.

## Reference

<Card title="Pagination — full reference contract" icon="arrow-down" href="/api-reference/conventions/pagination" horizontal>
  Request parameters, response shape, errors, URL encoding.
</Card>
