Skip to main content
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”.
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.

The two shapes at a glance

SurfaceShapePage withnext signalExample
Product data plane (families, devices, enforce history, webhook deliveries, viewing history)a bare JSON array?limit= and ?offset=array shorter than limit ⇒ last pageGET /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/querynext_cursor is null ⇒ last pagePOST /api/v1/csm/reviews/bulk
Static catalogs (platforms, ratings systems, standards)a bare JSON array, complete(not paginated)full set every timeGET /api/v1/platforms
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.

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.
curl
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"
Response · 200
[
  { "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.
Endpointlimit defaultlimit maxoffset
GET /api/v1/enforce/history20100
GET /api/v1/webhooks/{id}/deliveries50
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.
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;
  }
}
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.

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.
Response · 200 (advisor audit log)
{
  "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):
Request body
{ "slugs": ["bluey", "paw-patrol", "…"], "cursor": "", "idempotency_key": "…" }
TypeScript
// 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;
  }
}
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.

Choosing a page size

Bigger pages, fewer calls

Each request counts against your rate limit. Pull 100 at a time on offset lists rather than 10 at a time.

Detect the end correctly

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.

Next steps

Limits & quotas

The enforced limit ceilings per endpoint, plus body-size and ID-length caps.

Rate limits

Fewer, larger pages keep you inside your window.

Idempotency

Cursor rails are signed writes — see how replay-safety works.