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

# Pagination and date windows

> Page through list endpoints with opaque cursors, and pick a reporting period with the from and to date window.

Every list endpoint returns one page at a time. You move forward with a cursor taken from the previous page. Endpoints that report on a period also take a [date window](#date-windows).

## The list envelope

A list response has two top-level keys: `data`, the items on this page, and `pagination`.

```json theme={null}
{
  "data": [
    {
      "id": "c2a4e6f8-1b3d-4c5e-8f70-9a1b2c3d4e5f",
      "brand_id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
      "text": "best boutique hotels in Lisbon",
      "tags": ["city-breaks"],
      "countries": ["GB", "IE"],
      "platforms": ["perplexity", "chatgpt", "gemini", "claude", "ai_overviews"],
      "active": true,
      "source": "api",
      "created_by_key_id": "5e9d2c7a-8b41-4f6e-a3d0-1c2b3a4d5e6f",
      "created_at": "2026-09-01T09:30:00Z",
      "updated_at": "2026-09-01T09:30:00Z"
    }
  ],
  "pagination": {
    "next_cursor": "b3BhcXVlLWN1cnNvci1leGFtcGxl…",
    "has_more": true
  }
}
```

<ResponseField name="data" type="array" required>
  The items on this page. An empty array is a valid page.
</ResponseField>

<ResponseField name="pagination.next_cursor" type="string | null" required>
  The cursor for the next page. `null` on the last page.
</ResponseField>

<ResponseField name="pagination.has_more" type="boolean" required>
  `true` while another page exists. Stop when it is `false`.
</ResponseField>

A single resource comes back as `{ "data": { … } }` with no `pagination` key.

## `limit` and `cursor`

<ParamField query="limit" type="integer" default={50}>
  Page size, from 1 to 200. The default is 50.

  `GET /orgs/{orgId}/brands/{brandId}/mentions` is the bulk-export endpoint and accepts up to 1000.

  A value outside the range answers `400 validation_error`.
</ParamField>

<ParamField query="cursor" type="string">
  The `pagination.next_cursor` value from the previous page. Leave it out to get the first page.
</ParamField>

Three rules keep a walk correct:

* **A cursor is opaque.** Copy `next_cursor` into the next request exactly as you received it. Do not decode it, edit it or build one yourself. Pass it like any other query value, URL-encoded by your HTTP client.
* **Stop on `has_more: false`.** `next_cursor` is `null` on that page.
* **Send `limit` on every request.** The cursor does not remember it. Without `limit`, the next page falls back to the default size. You can change `limit` between pages.

## A complete loop

Each sample yields every prompt in an organisation from `GET /orgs/{orgId}/prompts`. It waits out `429` and `503` responses using `Retry-After`, and gives up instead of sleeping when the wait is long. A spent monthly quota counts down to the end of the month, for example — see [Rate limits and quotas](/api/rate-limits).

<CodeGroup>
  ```bash cURL theme={null}
  ORG_ID="7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"

  # First page: filters go here.
  curl -s -G "https://api.surfais.com/v1/orgs/$ORG_ID/prompts" \
    --data-urlencode "limit=200" \
    --data-urlencode "active=true" \
    -H "Authorization: Bearer $SURFAIS_API_KEY"

  # Next page: set NEXT_CURSOR to pagination.next_cursor from the response above.
  # Send the cursor on its own. It restores active=true.
  curl -s -G "https://api.surfais.com/v1/orgs/$ORG_ID/prompts" \
    --data-urlencode "limit=200" \
    --data-urlencode "cursor=$NEXT_CURSOR" \
    -H "Authorization: Bearer $SURFAIS_API_KEY"
  ```

  ```python Python theme={null}
  import os
  import time

  import requests

  BASE_URL = "https://api.surfais.com/v1"
  API_KEY = os.environ["SURFAIS_API_KEY"]
  MAX_ATTEMPTS = 6
  MAX_WAIT_SECONDS = 120


  def get_page(path, params):
      """GET one page. Waits out 429 and 503 responses using Retry-After."""
      for _ in range(MAX_ATTEMPTS):
          response = requests.get(
              f"{BASE_URL}{path}",
              headers={"Authorization": f"Bearer {API_KEY}"},
              params=params,
              timeout=30,
          )
          if response.ok:
              return response.json()

          retryable = response.status_code in (429, 503)
          wait = int(response.headers.get("Retry-After", "5"))
          if not retryable or wait > MAX_WAIT_SECONDS:
              # Not worth sleeping on: a 4xx to fix, or a quota that resets next month.
              body = response.json()
              raise RuntimeError(
                  f'{response.status_code} {body["error"]["code"]} '
                  f'(request {body["request_id"]})'
              )
          time.sleep(wait)
      raise RuntimeError("Gave up after repeated 429/503 responses")


  def iter_prompts(org_id, **filters):
      """Yield every prompt in the organisation, one page at a time."""
      params = {"limit": 200, **filters}
      while True:
          page = get_page(f"/orgs/{org_id}/prompts", params)
          yield from page["data"]
          pagination = page["pagination"]
          if not pagination["has_more"]:
              return
          # Continue with the cursor alone: it restores the first page's filters.
          params = {"limit": 200, "cursor": pagination["next_cursor"]}


  if __name__ == "__main__":
      org_id = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
      for prompt in iter_prompts(org_id, active="true"):
          print(prompt["id"], prompt["text"])
  ```

  ```javascript Node.js theme={null}
  // Node 18+. Save as list-prompts.mjs and run: node list-prompts.mjs
  const BASE_URL = "https://api.surfais.com/v1";
  const API_KEY = process.env.SURFAIS_API_KEY;
  const MAX_ATTEMPTS = 6;
  const MAX_WAIT_SECONDS = 120;

  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  // GET one page. Waits out 429 and 503 responses using Retry-After.
  async function getPage(path, params) {
    const url = `${BASE_URL}${path}?${new URLSearchParams(params)}`;
    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
      const response = await fetch(url, {
        headers: { Authorization: `Bearer ${API_KEY}` },
      });
      if (response.ok) return response.json();

      const retryable = response.status === 429 || response.status === 503;
      const wait = Number(response.headers.get("Retry-After") ?? 5);
      if (!retryable || wait > MAX_WAIT_SECONDS) {
        // Not worth sleeping on: a 4xx to fix, or a quota that resets next month.
        const body = await response.json();
        throw new Error(
          `${response.status} ${body.error.code} (request ${body.request_id})`,
        );
      }
      await sleep(wait * 1000);
    }
    throw new Error("Gave up after repeated 429/503 responses");
  }

  // Yield every prompt in the organisation, one page at a time.
  async function* iterPrompts(orgId, filters = {}) {
    let params = { limit: "200", ...filters };
    while (true) {
      const page = await getPage(`/orgs/${orgId}/prompts`, params);
      yield* page.data;
      if (!page.pagination.has_more) return;
      // Continue with the cursor alone: it restores the first page's filters.
      params = { limit: "200", cursor: page.pagination.next_cursor };
    }
  }

  const orgId = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11";
  for await (const prompt of iterPrompts(orgId, { active: "true" })) {
    console.log(prompt.id, prompt.text);
  }
  ```
</CodeGroup>

## What a cursor is bound to

A cursor is a position in one series. It remembers which series, so a walk cannot drift into a different one half-way through. A cursor that does not fit the request answers `400 invalid_cursor`.

**The endpoint and the collection.** A cursor belongs to the list that issued it and to the organisation, brand or prompt in that request's path. A cursor from one organisation's prompts does not work on another organisation's prompts, or on any other list. On `GET /orgs` it belongs to the owner of your key. Two endpoints are stricter still:

* A cursor from `GET /orgs/{orgId}/prompts/{promptId}/results` also belongs to the own brand the prompt was under. It stops working if the prompt is moved to another own brand.
* `GET /orgs/{orgId}/brands/{brandId}/scores` serves two series, one with `country` and one without. They issue distinct cursors, so adding `country` to a walk that started without it is refused.

None of these cases carries `details`.

**The filters of the first page.** The filters an endpoint takes — `platform`, `country`, `brand_id`, `active`, `tag`, `is_own` or `status` — travel inside the cursor. Continue with the cursor alone and they are restored, so page two is still filtered the way page one was. Repeating the same filter value is fine, so you can send your whole query string again with the cursor added. Naming a *different* value, or adding a filter the walk did not start with, answers `400 invalid_cursor` with `details.reason` set to `filter_changed`. `details.parameter` names the filter.

**The date window of the first page.** On an endpoint that takes `from` and `to`, the first page fixes the window and the cursor carries it. A walk that starts without `from` and `to` does not shift forward when it crosses midnight UTC. Naming a different `from` or `to` on a later page answers `400 invalid_cursor` with `details.reason` set to `window_changed`.

```json theme={null}
{
  "error": {
    "code": "invalid_cursor",
    "message": "The cursor was issued for a different `platform`; omit it to continue this walk, or start again from the first page.",
    "details": { "reason": "filter_changed", "parameter": "platform" }
  },
  "request_id": "0b8f6c2e-5d1a-4f3b-9c7e-2a4d6e8f0b13"
}
```

| `details.reason` | What happened                                                                               | What to do                                                       |
| ---------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| *(no `details`)* | The cursor was not issued by this list for this collection, or it is malformed.             | Request the first page without a cursor.                         |
| `filter_changed` | The request names a filter value the walk did not start with. `details.parameter` names it. | Drop the filter to continue, or start again to change it.        |
| `window_changed` | The request names a `from` or `to` the walk did not start with.                             | Drop `from` and `to` to continue, or start again to change them. |
| `stale`          | The brand's data changed since the cursor was issued. See the next section.                 | Start again from the first page.                                 |

To change a filter or the window, start again from the first page.

## Reading results while a scan is running

Three endpoints read scan results:

* `GET /orgs/{orgId}/prompts/{promptId}/results`
* `GET /orgs/{orgId}/brands/{brandId}/mentions`
* `GET /orgs/{orgId}/brands/{brandId}/sources`

A walk through any of them belongs to one *scan generation*. Every page serves only the results that were final when you asked for the first page. A result that becomes final while you page appears in no page of that walk, and in every page of the next one. You never get a page stitched together from two states of the data. [Data model](/api/data-model) explains what "final" means.

The price of that guarantee is that a walk can be refused part-way:

* **`400 invalid_cursor` with `details.reason` set to `stale`.** The brand's data changed after the cursor was issued. A running scan is the usual cause, because every result it finalises counts as a change. Start again from the first page.
* **`409 scan_in_progress` with `Retry-After`.** Results were being finalised while a first page was read, so it could not be served from a single scan generation. Expect it while a scan of the brand runs: from the sources endpoint at any page size, and from the results and mentions endpoints on a first page that takes more than one read: a large page, or one that reaches the end of the data. Wait `Retry-After` seconds, then send the same request again.

In practice:

1. **Wait for the scan to settle.** Poll the brand's change token: `last_scan_changed_at` on `GET /orgs/{orgId}/brands/{brandId}`, or `changed_at` on each row of `GET /orgs/{orgId}/brands/summary`. Start a bulk walk once the value has stopped moving.
2. **Use smaller pages while a scan runs.** A smaller first page is less likely to answer `409 scan_in_progress`, though any first page can.
3. **Make the walk restartable.** On `stale`, discard what you collected in this walk and begin again from the first page. Do not try to resume from the old cursor.

[Export results in bulk](/api/guides/export-results) has a complete backfill script that handles both responses.

<Note>
  Lists that do not read scan results never answer `stale` or `scan_in_progress`. That covers organisations, brands, prompts, scores and the webhook lists.
</Note>

## Date windows

Five endpoints report on a period and take a `from` and `to` window:

* `GET /orgs/{orgId}/prompts/{promptId}/results`
* `GET /orgs/{orgId}/brands/{brandId}/mentions`
* `GET /orgs/{orgId}/brands/{brandId}/scores`
* `GET /orgs/{orgId}/brands/{brandId}/share-of-voice`
* `GET /orgs/{orgId}/brands/{brandId}/sources`

<ParamField query="from" type="string">
  First day of the window, inclusive. The default is 30 days before `to`.
</ParamField>

<ParamField query="to" type="string">
  Last day of the window, inclusive. The default is today, in UTC.
</ParamField>

Both are calendar dates in UTC, written `YYYY-MM-DD`. The window includes both days.

`to` can be at most 366 days after `from`. On `GET /orgs/{orgId}/brands/{brandId}/sources` it can be at most 90 days after `from`, because that endpoint adds up every result in the window on each request.

```bash theme={null}
# Set ORG_ID and BRAND_ID to ids your key can reach.
curl -s -G "https://api.surfais.com/v1/orgs/$ORG_ID/brands/$BRAND_ID/scores" \
  --data-urlencode "from=2026-08-01" \
  --data-urlencode "to=2026-08-31" \
  -H "Authorization: Bearer $SURFAIS_API_KEY"
```

A window the API cannot accept answers `400 validation_error`. The reason is in `details[].code`:

| `details[].code`  | When                                                                                                           |
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
| `window_inverted` | `from` is after `to`.                                                                                          |
| `window_too_wide` | The window is wider than the endpoint allows.                                                                  |
| `invalid_date`    | `from` or `to` is not a real calendar date between `0001-01-01` and `9999-12-31`. `2026-02-30` is one example. |

A value that is not written as `YYYY-MM-DD` also answers `400 validation_error`.

On a paginated endpoint the first page fixes the window for the whole walk, as described in [What a cursor is bound to](#what-a-cursor-is-bound-to). See [Errors](/api/errors) for the full error format.
