# Pagination

List operations return results in pages. This page explains the `limit` and `cursor` parameters, the list envelope and how cursors behave.

## Parameters

| Parameter | Type | Meaning |
| --- | --- | --- |
| `limit` | integer, 1 to 100 | Most objects to return. Default `25`. Outside the range: [`validation_error`](/errors/validation_error). |
| `cursor` | string | The `next_cursor` of the previous page, exactly as returned. Omit it for the first page. |

Every list response has the same envelope:

```json
{
  "object": "list",
  "data": [
    { "id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6", "object": "call", "livemode": false, "status": "ended" },
    { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued" }
  ],
  "has_more": true,
  "next_cursor": "eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiJdfQ.c2lnbmF0dXJl"
}
```

| Field | Meaning |
| --- | --- |
| `data` | The objects on this page (full objects, as from the single-object read). |
| `has_more` | `true` when more objects exist after this page. |
| `next_cursor` | Pass as `cursor` to get the next page. `null` on the last page. |

(Objects in this example are shortened, and the cursor value is illustrative.)

## Order

Most lists are **newest first**: by `created_at` descending, then by id. A few lists have a different, stable order:

- [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers): by number.
- [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients): in a stable order that is not by time and has no other meaning.
- [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries): in test mode newest entry first; in live mode by contact, newest first.

Objects created while you page through a newest-first list appear at the start, so they are not on later pages: start a new listing (or use `created_after`) to pick them up.

## Iterating

Request pages until `has_more` is `false`, passing each page's `next_cursor` as `cursor` and keeping every filter the same. A page can hold fewer than `limit` objects while `has_more` is still `true` (this happens when you filter calls by `status`), so always use `has_more`, never the page size, to decide when to stop.

The first page (here one call per page, to keep the example short):

```bash
curl "https://voice-api.relationsai.com/v1/calls?limit=1" \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/calls?limit=1", {
  headers: {
    Authorization: "Bearer <token>",
  },
});
console.log(response.status, await response.json());
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/calls?limit=1",
    headers={
        "Authorization": "Bearer <token>",
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6",
      "object": "call",
      "livemode": false,
      "status": "ended",
      "outcome": "no_answer",
      "end_reason": "no_answer",
      "direction": "outbound",
      "source": "api",
      "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
      "campaign_id": null,
      "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu",
      "from": "+12025550150",
      "to": "+12025550101",
      "metadata": {},
      "created_at": "2026-10-21T09:35:00.000Z",
      "ended_at": "2026-10-21T09:35:18.000Z",
      "duration_seconds": 0,
      "billable_minutes": 0,
      "cost": {
        "amount": 0,
        "currency": "INR"
      },
      "artifacts": {
        "transcript": {
          "state": "absent"
        },
        "recording": {
          "state": "absent"
        },
        "analysis": {
          "state": "absent"
        }
      },
      "settled": true,
      "settled_at": "2026-10-21T09:35:30.000Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw"
}
```

`has_more` is `true`, so request the next page with `cursor` set to `next_cursor`, exactly as returned, and the same filters (`limit` may change):

```bash
curl "https://voice-api.relationsai.com/v1/calls?limit=1&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw" \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/calls?limit=1&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw", {
  headers: {
    Authorization: "Bearer <token>",
  },
});
console.log(response.status, await response.json());
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/calls?limit=1&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw",
    headers={
        "Authorization": "Bearer <token>",
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7",
      "object": "call",
      "livemode": false,
      "status": "queued",
      "outcome": null,
      "end_reason": null,
      "direction": "outbound",
      "source": "api",
      "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
      "campaign_id": null,
      "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu",
      "from": "+12025550150",
      "to": "+12025550100",
      "metadata": {
        "crm_lead_id": "L-20931"
      },
      "created_at": "2026-10-21T09:30:00.000Z",
      "ended_at": null,
      "duration_seconds": null,
      "billable_minutes": null,
      "cost": null,
      "artifacts": {
        "transcript": {
          "state": "pending"
        },
        "recording": {
          "state": "pending"
        },
        "analysis": {
          "state": "pending"
        }
      },
      "settled": false,
      "settled_at": null
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

`has_more` is `false` and `next_cursor` is `null`: this was the last page. (The cursor in these examples is illustrative; real cursors are opaque.)

Each page request counts against the `read` [rate limit](/fundamentals/rate-limits). Use `limit=100` and filters to read fewer pages.

The complete runnable example reads every page of ended calls from the last 24 hours, with the same filters on every page, and counts them by outcome.

Complete runnable example: [cURL script](/examples/fundamentals/pagination.sh) · [JavaScript](/examples/fundamentals/pagination.mjs) · [Python](/examples/fundamentals/pagination.py)

## Cursor rules

- Cursors are **opaque**: do not decode, edit, build or shorten them. They are URL-safe strings of up to 512 characters.
- A cursor is **bound to the list and the filters** that produced it, and to your workspace and mode. Send it only to the same list (for recipients and deliveries, the same campaign or endpoint id in the path) with exactly the same filters; `limit` may change between pages.
- A cursor that was modified or is sent with different filters answers [`invalid_cursor`](/errors/invalid_cursor) (400, `retry: no`); one longer than 512 characters answers [`validation_error`](/errors/validation_error). Start again without a cursor.
- Use a cursor to continue the listing you are reading now, not as a long-lived bookmark. To resume a sync later, store the `created_at` of the newest object you processed and list again with `created_after`.

To change filters, start a new listing from the first page.

## Errors

| Code | Cause | Fix |
| --- | --- | --- |
| [`invalid_cursor`](/errors/invalid_cursor) | Edited or truncated cursor, a cursor from another list, or filters changed between pages | Send `next_cursor` exactly as returned with the same filters, or start again without a cursor. |
| [`validation_error`](/errors/validation_error) | `limit` below 1 or above 100, an unknown query parameter, or a parameter given twice | Fix the query string. `details.errors` names the parameter. |
