# Pagination

Lists come back in pages. You move to the next page with a cursor, not with page numbers or offsets.

## List response

Every list uses the same envelope:

```json
{
  "object": "list",
  "data": [ { "object": "lead", "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31" } ],
  "has_more": true,
  "next_cursor": "v1.k1.Qm9hcmRfMWZ4.c3RyaWN0bHlfb3BhcXVl.dGFnX2V4YW1wbGU.bWFjX2V4YW1wbGVfdmFsdWU"
}
```

| Field | Description |
|---|---|
| `object` | Always `list`. |
| `data` | The objects on the current page. |
| `has_more` | `true` if more data follows this page. |
| `next_cursor` | The cursor for the next page. `null` when there are no more pages. |
| `meta` | Additional details. Present on conversations and the event feed. |

Lists don't return a total count (`total`) or an offset (`offset`).

## Parameters

- `limit`: how many objects to return. Defaults to 50, from 1 to 100. The event feed allows up to 200.
- `cursor`: the `next_cursor` value from the previous response.

Record order is fixed: oldest to newest by creation time, with ties broken by `id`. There is no sort parameter. New records are added to the end of the list.

## How to page through a list

1. Send the first request with the filters you need.
2. Process `data`.
3. If `has_more` is `true`, repeat the request with the same filters and `cursor=<next_cursor>`.
4. Stop when `has_more` is `false`.

```js
async function listAll(path, params = {}) {
  const items = [];
  let cursor = null;
  do {
    const url = new URL(`https://api.melioralab.io/v1${path}`);
    for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
    if (cursor) url.searchParams.set('cursor', cursor);
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.MELIORA_API_KEY}` },
    });
    if (!res.ok) throw new Error(`Meliora API: ${res.status} ${res.headers.get('Request-Id')}`);
    const page = await res.json();
    items.push(...page.data);
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
  return items;
}

const leads = await listAll('/leads', { campaign_id: '4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14', limit: 100 });
```
```python
import os
import requests

def list_all(path, params=None):
    session = requests.Session()
    session.headers["Authorization"] = f"Bearer {os.environ['MELIORA_API_KEY']}"
    params = dict(params or {})
    items = []
    while True:
        resp = session.get(f"https://api.melioralab.io/v1{path}", params=params, timeout=15)
        resp.raise_for_status()
        page = resp.json()
        items.extend(page["data"])
        if not page["has_more"]:
            return items
        params["cursor"] = page["next_cursor"]

leads = list_all("/leads", {"campaign_id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "limit": 100})
```

## Cursor rules

- **Cursors are opaque.** Don't parse or build them yourself. Just pass them back.
- **A cursor is bound to its request.** The filters on the next request must match the first one. If the filters differ, or the key's permissions or scope have changed, you get 400 `cursor_mismatch`.
- **You can change `limit`** between pages. That isn't an error.
- **A list cursor lasts 15 minutes.** An expired cursor returns 410 `cursor_expired`: start again from the first page. An event feed cursor lasts 30 days. See [Polling events](https://docs.melioralab.io/en/events-polling/).
- **A page can be shorter than `limit`.** A response never exceeds 1 MiB, so a long page is cut short and the remaining records come on the next page. Rely on `has_more`, not on the record count.

## Lists aren't snapshots

You read a list page by page, and the data can change between requests: leads get added and statuses get updated. Records are never skipped or repeated because of shifting offsets, but a lead's status on the first page may have changed by the time you read the last one. If you need a consistent state, combine the list walk with the [event feed](https://docs.melioralab.io/en/events-polling/): events after your anchor show what changed.
