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

# Registers and pagination

> Understand what a register is, page through one with a cursor or an offset, read its totals and facets, and walk a whole register safely.

A register is a paged, filterable list over one kind of record, sorted and filtered on the server and bounded to the rows the person may open. Where a family read returns one dataset as it is stored, a register composes a list screen: one row per record, with the columns a list needs. Most registers also return the numbers a list screen needs beside the page: a total that matches the filters, facet counts for the filter controls, and sums over the filtered set.

Registers are dedicated routes under `/api/v1/records/registers/`. The set of registers is fixed and defined per connected system type, and each register has its own filters, sort keys and page sizes. This page covers the mechanics every register shares.

## How a register is read

1. Send the first request with the filters, the sort, the selection ([Authority and scope](/data-services/scope#selection)) and the identity headers.
2. Nexio resolves the person's scope, applies it, then applies your filters inside it.
3. The response carries `data` (the page), `page` (the continuation), the numbers the register computes, and the `serving` and `scope` blocks.
4. Follow `page.next_cursor` to the next page, or send `offset` to jump to a row number on a register that accepts it.

Every register takes `limit`, `cursor` and `connection_id`. Each register sets its own default and maximum page size, and either clamps a `limit` above its maximum or refuses it with 400 `invalid_request`.

## Two ways to page

* **Cursor.** Follow `page.next_cursor` until it is `null`. This is the right way to read a whole register. On most registers the cursor is a keyset cursor: each page continues after the last row the previous page returned. A register may instead use an offset cursor, which stores the next row number. Each page is a new current read ([When the data changes during a walk](#when-the-data-changes-during-a-walk)).
* **Offset (page jump).** On a register that accepts it, send `offset` to jump to a row number, for a numbered-page screen. The largest offset is 1,000,000. When you send `offset`, the response echoes the applied window in `page.offset` and `page.page_size`. `next_cursor` is still returned and still says whether more rows exist.

Sending both `offset` and `cursor` answers 400 `invalid_request`: a cursor already fixes the position.

## What a cursor fixes, and what you resend

A cursor is opaque. It carries the continuation position and the filters it fixes. It does not hold later pages to the time of the first page, and it never carries permissions: authority is resolved again on every page.

Which filters a cursor fixes differs by register. On most registers the first page's filters and sort are fixed by the cursor, and sending one of them again with the cursor answers 400 `cursor_filter_mismatch`. Some registers instead record the filters in the cursor and compare them, so you resend the same values; a different value answers 400 `cursor_filter_mismatch`.

The rule to remember: the identity headers, `connection_id`, `mine` and `book` are never inside a cursor. Resend them on every page.

## Page, totals and facets

* `page.limit` is the number of rows on this page, not the limit you asked for, on most registers; one register echoes the effective limit instead.
* `page.next_cursor` is `null` on the last page. An empty `data` array with a `null` cursor is a complete, empty answer.
* `total` counts every row that matches the filters, before paging.
* `facets` counts rows per filter value, for the filter controls. The total and the facets are counted over the same authorized base population, the rows the person may read. The total applies every filter. A facet does not always: each register defines its facet filters, and a facet usually leaves out its own dimension, so choosing one value still shows the counts for the others. Read facet counts as "what you would get if you picked this", not as a breakdown of the total.
* `slice` is arithmetic over the filtered set, and `totals`, where a register sends it, is the person's whole population under the owner and office filters.
* Numbers are exact decimal text, for example `"48250.00"`. Parse them with a decimal library. A sum with nothing to add reads `null`, not `0`.

Where a register computes its page and its numbers in one warehouse statement, they describe the same data. Where it uses separate statements, even concurrent ones in the same request, a source change between them can make the numbers and the page disagree.

## What complete means for a register

| Signal                                            | Meaning                                                                                                                  |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `page.next_cursor` is `null`                      | The last page. No more rows follow.                                                                                      |
| `page.next_cursor` is a string                    | This page is complete; more rows follow.                                                                                 |
| `note` on the response                            | Part of the page could not be completed, and the note names what. Render what you have and say it is partial.            |
| 409 `book_unavailable`, reason `result_too_large` | The read matched more rows than a statement's declared ceiling. A register never truncates silently: narrow the filters. |
| 409 or 503 `book_unavailable` with another reason | The register could not be served now. It says nothing about whether rows exist.                                          |

The full outcome model is on [Completeness and errors](/data-services/completeness).

## Walk a whole register safely

1. Send the first request with every filter, the sort, the selection and the identity headers.
2. For each next page, send `cursor` plus only what the register's resend rule says. Never rebuild the filters from the previous page's rows.
3. Stop when `page.next_cursor` is `null`.
4. On 409 `cursor_expired`, start again from the first page. The cursor no longer matches how the register is served. A current-read cursor has no age limit of its own.
5. On 503 `book_unavailable` with reason `busy`, wait for `Retry-After` seconds and resend the same page. On other `book_unavailable` reasons, see [Completeness and errors](/data-services/completeness).

The same walk applies to the [generic family read](/data-services/families#pagination-and-cursors), whose cursor fixes the first page's family and key filter. The example walks a whole family: `rawams_vocabulary/prcode`, the code dictionary of the connected system, which takes no key filter.

<CodeGroup>
  ```bash curl theme={null}
  # First page. Save page.next_cursor from the response.
  curl "https://api.usenexio.com/api/v1/records/families/rawams_vocabulary/prcode?limit=500" \
    -H "Authorization: Bearer $NEXIO_API_KEY" \
    -H "X-Nexio-Acting-Principal: user_01J8Z3K4M5N6P7Q8R9S0T1U2V3" \
    -H "X-Nexio-Acting-Email: dana.ortiz@harborgroup.example"

  # Next page. The cursor fixes the family and the key filter, so send only the cursor and limit.
  curl "https://api.usenexio.com/api/v1/records/families/rawams_vocabulary/prcode?cursor=CURSOR_FROM_PREVIOUS_PAGE&limit=500" \
    -H "Authorization: Bearer $NEXIO_API_KEY" \
    -H "X-Nexio-Acting-Principal: user_01J8Z3K4M5N6P7Q8R9S0T1U2V3" \
    -H "X-Nexio-Acting-Email: dana.ortiz@harborgroup.example"
  ```

  ```python Python theme={null}
  import os
  import time

  import requests

  BASE = "https://api.usenexio.com/api/v1/records/families/rawams_vocabulary/prcode"
  HEADERS = {
      "Authorization": "Bearer " + os.environ["NEXIO_API_KEY"],
      "X-Nexio-Acting-Principal": "user_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "X-Nexio-Acting-Email": "dana.ortiz@harborgroup.example",
  }
  FIRST_PAGE = {"limit": 500}


  def walk_family() -> list[dict]:
      rows: list[dict] = []
      params: dict = dict(FIRST_PAGE)
      while True:
          response = requests.get(BASE, params=params, headers=HEADERS, timeout=60)
          if response.status_code == 503:
              time.sleep(int(response.headers.get("Retry-After", "5")))
              continue
          if response.status_code == 409:
              error = response.json()
              if error["code"] == "cursor_expired":
                  rows, params = [], dict(FIRST_PAGE)
                  continue
          response.raise_for_status()
          page = response.json()
          rows.extend(dict(zip(page["columns"], row)) for row in page["data"])
          cursor = page["page"]["next_cursor"]
          if cursor is None:
              return rows
          params = {"cursor": cursor, "limit": 500}


  print(len(walk_family()))
  ```

  ```typescript TypeScript theme={null}
  const BASE = "https://api.usenexio.com/api/v1/records/families/rawams_vocabulary/prcode";
  const HEADERS = {
    Authorization: `Bearer ${process.env.NEXIO_API_KEY}`,
    "X-Nexio-Acting-Principal": "user_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
    "X-Nexio-Acting-Email": "dana.ortiz@harborgroup.example",
  };
  const FIRST_PAGE = { limit: "500" };

  async function walkFamily(): Promise<Record<string, unknown>[]> {
    let rows: Record<string, unknown>[] = [];
    let params: Record<string, string> = { ...FIRST_PAGE };
    for (;;) {
      const response = await fetch(`${BASE}?${new URLSearchParams(params)}`, { headers: HEADERS });
      if (response.status === 503) {
        const seconds = Number(response.headers.get("Retry-After") ?? "5");
        await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
        continue;
      }
      if (response.status === 409) {
        const error = await response.json();
        if (error.code === "cursor_expired") {
          rows = [];
          params = { ...FIRST_PAGE };
          continue;
        }
        throw new Error(`family read refused: ${error.code}`);
      }
      if (!response.ok) throw new Error(`family read failed: ${response.status}`);
      const page = await response.json();
      for (const row of page.data as unknown[][]) {
        rows.push(Object.fromEntries(page.columns.map((column: string, i: number) => [column, row[i]])));
      }
      if (page.page.next_cursor === null) return rows;
      params = { cursor: page.page.next_cursor, limit: "500" };
    }
  }

  console.log((await walkFamily()).length);
  ```
</CodeGroup>

The last page of the walk has fewer rows than the limit and a `null` cursor:

```json 200 OK theme={null}
{
  "plane": "rawams_vocabulary",
  "family": "prcode",
  "grain": "table",
  "columns": [
    "ams360_datasource",
    "attrcode",
    "category",
    "changedby",
    "changeddate",
    "code",
    "description",
    "entereddate",
    "id",
    "ishide",
    "permflag",
    "sortno",
    "source_deleted"
  ],
  "data": [
    ["HIG01", "CONTACTMETHOD", "4", "ADM01", "2026-05-11T09:30:00Z", "EM", "Email", "2019-02-01T12:00:00Z", "88213", "N", "1", "2", "false"],
    ["HIG01", "CONTACTMETHOD", "4", "ADM01", "2026-05-11T09:30:00Z", "PH", "Phone", "2019-02-01T12:00:00Z", "88214", "N", "1", "1", "false"]
  ],
  "page": {
    "limit": 2,
    "next_cursor": null
  },
  "serving": {
    "binding_id": "2c4e6a8b-0d1f-4a3c-9e5b-7d9f1a3c5e7b",
    "overlay_rev": 0,
    "as_of": "2026-09-23T14:31:40Z",
    "source": {
      "mode": "query_first",
      "current": true,
      "fetched_at": "2026-09-23T14:31:40Z"
    }
  },
  "family_serving": {
    "family": "prcode",
    "plane": "rawams_vocabulary",
    "presence": "present",
    "batch_set_id": null,
    "published_at": null
  },
  "scope": { "kind": "Self", "selection": "boundary", "selection_source": "none" }
}
```

### When the data changes during a walk

No register holds a walk to one instant. Each page reads its sources again at the time of that page's request, then continues in the same sort order: after the last row the previous page returned, or, on a register with an offset cursor, at the next row number. So a source change during a walk can show up in the pages that follow:

* A row created, or changed so that it now sorts after the cursor position, can appear on a later page.
* A row changed so that it sorts before the cursor position can be missed; one that moves from before to after it can appear twice.
* A row that stops matching the filters, or leaves the person's scope, is absent from later pages.
* On a register that pages by row number, a row added or removed before the cursor position shifts every row after it. An unchanged row can then appear twice or be skipped.
* `total`, `facets` and the other numbers are computed again on each page, so they can differ from page to page.

For a consistent count, read the numbers from one page. To pick up changes made during a walk, start a new walk.

## Cursor and paging errors

| Status | Code                     | Cause                                                                                                                                                                                                                          | Fix                                 |
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
| 400    | `invalid_request`        | `limit` or `offset` not a valid number, `offset` with `cursor`, a limit above a refusing register's maximum, an offset above 1,000,000, or a filter value outside its allowed set (for example an unknown `sort` or `status`). | Correct the parameter.              |
| 400    | `invalid_cursor`         | The cursor is malformed, or belongs to a different request shape.                                                                                                                                                              | Use the cursor exactly as returned. |
| 400    | `cursor_filter_mismatch` | A fixed filter was sent with the cursor, or a compared value differs from the first page.                                                                                                                                      | Follow the register's resend rule.  |
| 409    | `cursor_expired`         | The cursor no longer matches how the register is served, for example it does not carry the current-read marker.                                                                                                                | Restart from the first page.        |

## Next

<CardGroup cols={2}>
  <Card title="Completeness and errors" href="/data-services/completeness">Tell an empty list from an unavailable one.</Card>
  <Card title="Families and the generic read" href="/data-services/families">Read any registered family by name.</Card>
</CardGroup>
