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

# Pagination

> Phosra has two list shapes — offset lists on the product plane and cursor lists on the OCSS census rails. Here is exactly which is which, and how to page each.

Phosra returns lists in **two** shapes, and which one you get depends on the
surface — not on a query flag. Read this once and you will never wonder whether a
response has "more".

<Info>
  **Runnable.** The shapes below were captured live from
  `https://phosra-api-sandbox-production.up.railway.app` on **2026-07-06**. The
  catalog reads (`/api/v1/platforms`, `/api/v1/ratings/systems`) need no API key.
</Info>

## The two shapes at a glance

| Surface                                                                                          | Shape                                                    | Page with                  | `next` signal                          | Example                                          |
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | -------------------------- | -------------------------------------- | ------------------------------------------------ |
| **Product data plane** (families, devices, enforce history, webhook deliveries, viewing history) | a **bare JSON array**                                    | `?limit=` and `?offset=`   | array shorter than `limit` ⇒ last page | `GET /api/v1/enforce/history?limit=20&offset=40` |
| **OCSS census rails** (advisor audit log, CSM bulk ratings)                                      | an **envelope** `{ "<items>": [...], "next_cursor": … }` | `cursor` in the body/query | `next_cursor` is `null` ⇒ last page    | `POST /api/v1/csm/reviews/bulk`                  |
| **Static catalogs** (platforms, ratings systems, standards)                                      | a **bare JSON array**, complete                          | *(not paginated)*          | full set every time                    | `GET /api/v1/platforms`                          |

<Warning>
  **There is no `has_more`, no `total_count`, and no `"object": "list"` wrapper.**
  Product lists are bare arrays; census lists carry `next_cursor`. Do not code
  against fields Phosra does not return — detect the last page by the two rules in
  the table above.
</Warning>

***

## Offset lists (product data plane)

Most product list endpoints return a **plain array** and accept `limit` and, where
supported, `offset`. An empty result is `[]`, never `null`.

```bash curl theme={null}
BASE=https://phosra-api-sandbox-production.up.railway.app
curl -s -H "Authorization: Bearer $PHOSRA_API_KEY" \
  "$BASE/api/v1/enforce/history?limit=20&offset=0"
```

```json Response · 200 theme={null}
[
  { "id": "b1f0…", "status": "completed", "created_at": "2026-07-05T18:20:10Z" },
  { "id": "a7c2…", "status": "completed", "created_at": "2026-07-05T18:19:55Z" }
]
```

### Limits and defaults

Each endpoint sets its own default and ceiling. Ask for more than the ceiling and
you get the ceiling.

| Endpoint                                | `limit` default | `limit` max | `offset` |
| --------------------------------------- | --------------- | ----------- | -------- |
| `GET /api/v1/enforce/history`           | `20`            | `100`       | ✅        |
| `GET /api/v1/webhooks/{id}/deliveries`  | `50`            | —           | —        |
| `GET /api/v1/viewing-history/{childId}` | repo default    | —           | ✅        |

### Walk every page

Keep advancing `offset` by `limit` until you get back **fewer** rows than you
asked for — that short page is the last one.

<CodeGroup>
  ```typescript TypeScript theme={null}
  async function* allEnforceJobs(base: string, key: string, pageSize = 100) {
    let offset = 0;
    for (;;) {
      const res = await fetch(
        `${base}/api/v1/enforce/history?limit=${pageSize}&offset=${offset}`,
        { headers: { Authorization: `Bearer ${key}` } },
      );
      const page: unknown[] = await res.json();
      yield* page;
      if (page.length < pageSize) return; // short page ⇒ done
      offset += pageSize;
    }
  }
  ```

  ```python Python theme={null}
  import requests

  def all_enforce_jobs(base, key, page_size=100):
      offset = 0
      while True:
          res = requests.get(
              f"{base}/api/v1/enforce/history",
              params={"limit": page_size, "offset": offset},
              headers={"Authorization": f"Bearer {key}"},
          )
          page = res.json()
          yield from page
          if len(page) < page_size:  # short page ⇒ done
              return
          offset += page_size
  ```

  ```bash curl theme={null}
  BASE=https://phosra-api-sandbox-production.up.railway.app
  LIMIT=100; OFFSET=0
  while :; do
    PAGE=$(curl -s -H "Authorization: Bearer $PHOSRA_API_KEY" \
      "$BASE/api/v1/enforce/history?limit=$LIMIT&offset=$OFFSET")
    N=$(echo "$PAGE" | python3 -c "import sys,json;print(len(json.load(sys.stdin)))")
    echo "$PAGE"
    [ "$N" -lt "$LIMIT" ] && break   # short page ⇒ done
    OFFSET=$((OFFSET + LIMIT))
  done
  ```
</CodeGroup>

<Note>
  **Offset lists are newest-first and mutable.** New rows land at `offset 0`, so a
  row can shift while you page. For an audit-stable walk, filter by a fixed
  `created_at`/`since` window rather than relying on offsets across a long crawl.
</Note>

***

## Cursor lists (OCSS census rails)

The OCSS census rails return an **envelope** with the collection plus a
`next_cursor`. The cursor is **opaque** — treat it as a token, never parse it — and
`null` means you have reached the end. Pass it back verbatim to get the next page.

```json Response · 200 (advisor audit log) theme={null}
{
  "consultations": [
    { "advisor_id": "did:ocss:…", "capability": "age_estimate", "at": "2026-07-05T…" }
  ],
  "next_cursor": null
}
```

For the CSM bulk rail the cursor rides the **request body** (it is part of the
signed, convergent payload — the same request set and cursor always return the same
records):

```json Request body theme={null}
{ "slugs": ["bluey", "paw-patrol", "…"], "cursor": "", "idempotency_key": "…" }
```

```typescript TypeScript theme={null}
// Cursor loop: pass next_cursor back until it comes back null.
async function* csmBulk(base: string, sign: SignFn, slugs: string[]) {
  let cursor = "";
  for (;;) {
    const body = JSON.stringify({ slugs, cursor });
    const res = await fetch(`${base}/api/v1/csm/reviews/bulk`, {
      method: "POST",
      headers: sign("POST", `${base}/api/v1/csm/reviews/bulk`, body),
      body,
    });
    const { reviews, next_cursor } = await res.json();
    yield* reviews;
    if (next_cursor == null) return; // null ⇒ done
    cursor = next_cursor;
  }
}
```

<Warning>
  **Never construct or mutate a cursor.** `next_cursor` is a server token. Send it
  back byte-for-byte; do not decode it, add to it, or assume it is a row id — its
  encoding is not part of the contract and can change.
</Warning>

***

## Choosing a page size

<CardGroup cols={2}>
  <Card title="Bigger pages, fewer calls" icon="gauge-high">
    Each request counts against your [rate limit](/rate-limits). Pull `100` at a
    time on offset lists rather than `10` at a time.
  </Card>

  <Card title="Detect the end correctly" icon="flag-checkered">
    Offset lists: a **short page** ends the walk. Cursor lists: a **null
    `next_cursor`** ends the walk. Never guess with a count you were not given.
  </Card>
</CardGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Limits & quotas" icon="ruler" href="/limits#pagination-page-sizes">
    The enforced `limit` ceilings per endpoint, plus body-size and ID-length caps.
  </Card>

  <Card title="Rate limits" icon="gauge-high" href="/rate-limits">
    Fewer, larger pages keep you inside your window.
  </Card>

  <Card title="Idempotency" icon="fingerprint" href="/idempotency">
    Cursor rails are signed writes — see how replay-safety works.
  </Card>
</CardGroup>
