---
title: Pagination
url: /docs/developers/pagination
---

# Pagination

Long answers come a page at a time with a cursor for the next page: the catalogue, and the rows of a table. When the cursor is `null`, there's nothing more.

## The catalogue

`GET /catalog` answers up to 100 entries a page:

```json title="Response"
{ "entries": [], "nextCursor": null }
```

When `nextCursor` is a string, fetch the next page with `GET /catalog?cursor=…`, passing it exactly. A catalogue cursor lasts 15 minutes and belongs to this key and this catalogue: if what the key can call changes in between, or the cursor is too old, the API answers `400` with `catalogue cursor is invalid`. Start again from the first page.

## Rows

[Read a list](reference/data-view-source-query.md) answers one page of rows, up to `pageSize` (1 to 200), and a `nextCursor`. To read the next page, send the same query again with that cursor in `query.cursor`:

```curl tabs
# The first page; send the answer's nextCursor as query.cursor for the next.
curl https://api.growlocal.site/api/v1/profiles/YOUR_BUSINESS_ID/invocations \
  -H "Authorization: Bearer gl_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "target": {
    "featureId": "YOUR_FEATURE_ID",
    "export": "queryRows",
    "contractRef": "data.view-source.query@1"
  },
  "operationId": "read-rows-page-1",
  "input": {
    "resourceRef": "YOUR_TABLE_REF",
    "query": { "pageSize": 100 }
  }
}'
```

```javascript tabs
// Every row of one table, 100 at a time.
const rows = [];
let cursor = null;
do {
  const response = await fetch(
    "https://api.growlocal.site/api/v1/profiles/YOUR_BUSINESS_ID/invocations",
    {
      method: "POST",
      headers: {
        Authorization: "Bearer gl_YOUR_API_KEY",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        target: {
          featureId: "YOUR_FEATURE_ID",
          export: "queryRows",
          contractRef: "data.view-source.query@1",
        },
        operationId: crypto.randomUUID(),
        input: {
          resourceRef: "YOUR_TABLE_REF",
          query: { pageSize: 100, ...(cursor === null ? {} : { cursor }) },
        },
      }),
    },
  );
  const receipt = await response.json();
  if (receipt.status !== "applied") throw new Error(JSON.stringify(receipt));
  rows.push(...receipt.output.rows);
  cursor = receipt.output.nextCursor;
} while (cursor !== null);
console.log(`${rows.length} rows`);
```

```python tabs
import uuid

import requests

# Every row of one table, 100 at a time.
rows = []
cursor = None
while True:
    query = {"pageSize": 100}
    if cursor is not None:
        query["cursor"] = cursor
    response = requests.post(
        "https://api.growlocal.site/api/v1/profiles/YOUR_BUSINESS_ID/invocations",
        headers={"Authorization": "Bearer gl_YOUR_API_KEY"},
        json={
            "target": {
                "featureId": "YOUR_FEATURE_ID",
                "export": "queryRows",
                "contractRef": "data.view-source.query@1",
            },
            "operationId": str(uuid.uuid4()),
            "input": {"resourceRef": "YOUR_TABLE_REF", "query": query},
        },
        timeout=30,
    )
    receipt = response.json()
    if receipt["status"] != "applied":
        raise RuntimeError(receipt)
    rows += receipt["output"]["rows"]
    cursor = receipt["output"]["nextCursor"]
    if cursor is None:
        break
print(f"{len(rows)} rows")
```

A row cursor belongs to its table and its query: between pages you may change `columns` and `pageSize`, but not the filters, search, sort order or which rows you read (current, archived or in Trash). A cursor sent with another query, or one that isn't a cursor, is refused with `DATA.CURSOR_INVALID`. A row cursor doesn't expire, and a row edited between two pages doesn't move where the next page starts.

Each page is a read, so every page may use a new `operationId`.
