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:

{ "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 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:

# 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 }
  }
}'
// 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`);
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.