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

# Pagination

> Walk any list endpoint with limit, cursor, and nextCursor, get a total count, and sync changes incrementally

Every list that grows with your data returns one page of results at a time, in the same shape:

```json theme={null}
{
  "data": [],
  "hasMore": true,
  "nextCursor": "eyJjIjoiY3JlYXRlZEF0IiwidiI6IjIwMjYtMTAtMDFUMDg6MDA6MDAuMDAwWiIsImlkIjoiY200azJ4OXAxMDAwMWFiY2QxMjM0ZWZnaCJ9"
}
```

| Field | Description |
| - | - |
| `data` | The items on this page. |
| `hasMore` | `true` if more items follow this page; `false` on the last page. |
| `nextCursor` | An opaque string to pass as `cursor` to get the next page, or `null` on the last page. |
| `total` | The number of items that match your filters. Present only when you send `include=total`. See [Get the total](#get-the-total). |

A paginated list takes the following query parameters:

| Parameter | Description |
| - | - |
| `limit` | The maximum number of items to return, from 1 to 100. Default: `25`. |
| `cursor` | The `nextCursor` from the previous response. Leave it out to get the first page. |
| `include` | `total` adds `total` to the response. |

To get the next page, send the same request again with `cursor` set to the `nextCursor` you received, and keep every other query parameter unchanged. Stop when `hasMore` is `false`.

<Note>
  This page describes API version `2026-10`. In `2026-09`, lists return the array under a name of their own, such as `documents`, and most page with `page` and `perPage`. In `2026-10`, `page` and `perPage` return `400 VALIDATION_FAILED`. For the differences, see [Upgrading to 2026-10](/upgrading/2026-10#pagination).
</Note>

## Walk a list

The following samples list every document in the workspace, 100 at a time:

<CodeGroup>
  ```bash curl theme={null}
  #!/usr/bin/env bash
  # Requires jq.
  cursor=""

  while true; do
    args=(--data-urlencode "limit=100")
    [ -n "$cursor" ] && args+=(--data-urlencode "cursor=$cursor")

    page=$(curl -sS --fail-with-body -G https://app.sajn.se/api/v1/documents \
      -H "Authorization: Bearer $SAJN_API_KEY" \
      -H "Sajn-Version: 2026-10" \
      "${args[@]}") || { echo "$page" >&2; exit 1; }

    echo "$page" | jq -c '.data[] | {id, name, status}'

    [ "$(echo "$page" | jq -r '.hasMore')" = "true" ] || break
    cursor=$(echo "$page" | jq -r '.nextCursor')
  done
  ```

  ```typescript TypeScript theme={null}
  // list-documents.ts. Requires Node.js 18 or later.
  const BASE_URL = 'https://app.sajn.se/api/v1';

  type Page<T> = { data: T[]; hasMore: boolean; nextCursor: string | null };

  async function* listAll<T>(
    path: string,
    params: Record<string, string> = {},
  ): AsyncGenerator<T> {
    let cursor: string | null = null;

    do {
      const query = new URLSearchParams({ limit: '100', ...params });
      if (cursor) query.set('cursor', cursor);

      const response = await fetch(`${BASE_URL}${path}?${query}`, {
        headers: {
          Authorization: `Bearer ${process.env.SAJN_API_KEY}`,
          'Sajn-Version': '2026-10',
        },
      });

      if (!response.ok) {
        throw new Error(`${response.status}: ${await response.text()}`);
      }

      const page = (await response.json()) as Page<T>;
      yield* page.data;
      cursor = page.hasMore ? page.nextCursor : null;
    } while (cursor);
  }

  type Document = { id: string; name: string; status: string };

  for await (const document of listAll<Document>('/documents')) {
    console.log(document.id, document.name, document.status);
  }
  ```

  ```python Python theme={null}
  # list_documents.py. Requires the requests package.
  import os

  import requests

  BASE_URL = "https://app.sajn.se/api/v1"
  HEADERS = {
      "Authorization": f"Bearer {os.environ['SAJN_API_KEY']}",
      "Sajn-Version": "2026-10",
  }


  def list_all(path, **params):
      params = {"limit": 100, **params}

      while True:
          response = requests.get(f"{BASE_URL}{path}", headers=HEADERS, params=params)
          response.raise_for_status()
          page = response.json()

          yield from page["data"]

          if not page["hasMore"]:
              return
          params["cursor"] = page["nextCursor"]


  for document in list_all("/documents"):
      print(document["id"], document["name"], document["status"])
  ```
</CodeGroup>

Each sample reads your API key from the `SAJN_API_KEY` environment variable. Every paginated list has the same shape, so to walk another list, change the path, such as `/contacts`. Add filters to the first request only; the loops keep them on every page.

A full walk of a large list sends many requests in a short time. To stay within your per-minute limit, see [Rate limits and quotas](/api-fundamentals/rate-limits).

## Paginated and bounded lists

Lists that grow with your data, such as [documents](/api-reference/list-all-documents), [contacts](/api-reference/list-all-contacts), [folders](/api-reference/list-folders), and a document's [comments](/api-reference/list-comment-threads-on-a-document), are paginated as this page describes.

Lists that their parent bounds aren't paginated. They return every item in one response, as `{ data, hasMore: false, nextCursor: null }`, and don't take `limit` or `cursor`. The following lists are bounded:

* A document's parties, fields, field values, signatures, files, links, reminders, and delegations.
* A template's parties and fields.
* The workspace's roles and permissions.
* The `/api/v1/helpers` lists, such as countries and currencies.

## Get the total

A list returns `total` only when you send `include=total`, because counting every match takes time. Ask for it on the first page only, when you need it, such as to show a count in your UI:

```bash theme={null}
curl -G https://app.sajn.se/api/v1/contacts \
  -H "Authorization: Bearer $SAJN_API_KEY" \
  -H "Sajn-Version: 2026-10" \
  --data-urlencode "limit=20" \
  --data-urlencode "include=total"
```

The response is similar to the following:

```json theme={null}
{
  "data": [],
  "hasMore": true,
  "nextCursor": "eyJwIjoyfQ",
  "total": 87
}
```

To know whether another page follows, read `hasMore`, not `total`.

## Sort a list

Most lists return the newest items first. The following lists also take `orderBy` and `orderDirection`, where `orderDirection` is `asc` or `desc`, with a default of `desc`:

| List | `orderBy` | Default |
| - | - | - |
| [`GET /api/v1/documents`](/api-reference/list-all-documents) | `createdAt`, `updatedAt`, `name`, `completedAt`, or `expiresAt` | `createdAt` |
| [`GET /api/v1/forms`](/api-reference/list-forms) | `createdAt` or `updatedAt` | `createdAt` |
| [`GET /api/v1/forms/{id}/submissions`](/api-reference/list-form-submissions) | `startedAt` or `updatedAt` | `startedAt` |
| [`GET /api/v1/members`](/api-reference/list-workspace-members) | `joinedAt`, `lastActiveAt`, or `name` | `joinedAt` |

Documents with no `completedAt` or `expiresAt` value sort last in both directions.

A cursor belongs to the sort order it was issued for. A cursor sent with a different `orderBy` returns `400 VALIDATION_FAILED`. To change the order, start again without `cursor`.

## Changes during a walk

The document, form, form submission, event, webhook, webhook delivery, tag, folder, approval request, document activity, and document message lists page by keyset: the cursor marks the last item you received. Items that are created or deleted while you walk never shift the items you haven't read yet, and every `orderBy` works the same way. This makes these lists the right choice for syncing data.

The other paginated lists, such as contacts and templates, page by position. If items are created or deleted while you walk one, items can shift between pages, so a walk can skip or repeat an item. Deduplicate by `id`.

### Sync changes incrementally

To keep a copy of your documents up to date, sort by the time of the last change, oldest first, and start at the newest change you've stored:

```bash theme={null}
curl -G https://app.sajn.se/api/v1/documents \
  -H "Authorization: Bearer $SAJN_API_KEY" \
  -H "Sajn-Version: 2026-10" \
  --data-urlencode "updatedAfter=2026-10-01T08:00:00Z" \
  --data-urlencode "orderBy=updatedAt" \
  --data-urlencode "orderDirection=asc" \
  --data-urlencode "limit=100"
```

Walk the cursor until `hasMore` is `false`, and then store the largest `updatedAt` you received for the next run. A document that changes during the walk moves to the end of the list, so the walk still reaches it. `updatedAfter` includes its boundary, so the next run returns the last document again; deduplicate by `id`.

Forms and form submissions work the same way with `updatedAfter` and `orderBy=updatedAt`. To reconcile webhook events you might have missed, see [Replay and reconcile](/webhooks/replay-and-reconcile).

## Invalid cursors

A cursor is opaque. Don't parse it, build it, or store it for later use; pass it back unchanged on the next request. A cursor that the API can't read returns `400 VALIDATION_FAILED`. To start over, send the request without `cursor`.

## Next steps

* [Query parameters](/api-fundamentals/query-parameters): filter formats, such as dates and multi-value filters.
* [Rate limits and quotas](/api-fundamentals/rate-limits): the per-minute and daily limits that a full walk counts against.
* [Errors](/api-fundamentals/errors): the error shape and every error code.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.