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

# Pagination

> Cursors, page sizes and result depth.

Every list response uses the same envelope, so one paging helper works for all of them.

```json theme={null}
{
  "object": "list",
  "data": [ /* … */ ],
  "has_more": true,
  "next_cursor": "eyJvIjoyNSwiayI6InJldiJ9",
  "total_count": 418,
  "period": { "start": "2026-08-16", "end": "2026-09-14", "granularity": "day", "adjusted": false },
  "currency": "USD"
}
```

<ResponseField name="has_more" type="boolean">
  Whether another page exists. This — not an empty `data` array — is the loop condition.
</ResponseField>

<ResponseField name="next_cursor" type="string | null">
  Pass as `cursor` on the next request. Null on the last page.
</ResponseField>

<ResponseField name="total_count" type="integer | null">
  Approximate number of matches. Null when the result set is too large to count exactly. Useful
  for a progress estimate, never as a loop bound.
</ResponseField>

## The loop

```python theme={null}
def paginate(session, url, body=None, params=None, page_size=100):
    cursor = None
    while True:
        if body is not None:
            payload = {**body, "limit": page_size, **({"cursor": cursor} if cursor else {})}
            page = session.post(url, json=payload, timeout=30).json()
        else:
            query = {**(params or {}), "limit": page_size, **({"cursor": cursor} if cursor else {})}
            page = session.get(url, params=query, timeout=30).json()

        yield from page["data"]

        if not page["has_more"]:
            return
        cursor = page["next_cursor"]
```

Search endpoints take the cursor in the JSON body; `GET` endpoints take it as a query parameter.
Otherwise the mechanics are identical.

## Cursor rules

<AccordionGroup>
  <Accordion title="Cursors are opaque" icon="lock">
    The encoding is an implementation detail and will change. Never decode one, construct one, or
    infer an offset from it.
  </Accordion>

  <Accordion title="A cursor pins the query" icon="thumbtack">
    Filters, sort, period and currency are captured when the first page is issued. Changing any of
    them invalidates the cursor — start a new search instead.
  </Accordion>

  <Accordion title="Cursors expire after 10 minutes" icon="clock">
    An expired cursor returns `400` with `cursor_expired`. If you are pausing between pages for
    longer than that, restart the search rather than holding the cursor.
  </Accordion>

  <Accordion title="Results are a snapshot" icon="camera">
    A cursor holds a consistent view for its lifetime, so an entity will not appear twice or vanish
    mid-scan because the data refreshed underneath you.
  </Accordion>
</AccordionGroup>

## Page size

`limit` accepts 1 to 100 and defaults to 25.

Use 100 for anything you are iterating. [Credits are charged per row](/docs/platform/credits), not
per request, so larger pages cost the same in credits and a quarter as much in
[rate limit](/docs/platform/rate-limits) budget.

<Tip>
  The default of 25 exists so an exploratory call in a terminal returns something readable. In code
  there is essentially no reason not to pass 100.
</Tip>

## Result depth

Searches page to a depth of **1,000 rows**. Past that, `has_more` is false even if `total_count`
is larger.

```python theme={null}
rows = list(paginate(session, ".../products/search", body=body))

if page["total_count"] and page["total_count"] > len(rows):
    log.info("truncated at %d of ~%d matches", len(rows), page["total_count"])
```

Detail and management endpoints — `GET /shops/{id}/products`, `GET /lists/{id}/items`,
`GET /jobs/{id}/results` — have no such cap. The limit applies to ranked search, where depth
beyond the top of the ranking is not meaningfully ordered.

To go deeper: narrow the filters, shard by category or period, or use an
[export job](/docs/workflows/jobs). See [Search](/docs/workflows/search#result-depth).

## What not to do

<Warning>
  Do not loop on `len(page["data"]) > 0`. A page can legitimately come back short — filtered rows
  are removed after the page is assembled — while `has_more` is still true. Breaking on a short
  page truncates the result set silently.
</Warning>

```python theme={null}
# Wrong — stops early on a short page
while page["data"]:
    ...

# Right
while page["has_more"]:
    ...
```
