# Pagination

URL: /docs/concepts/pagination

Page through lists with page and perPage, read links and meta, use cursors on merchant lists, and sync CRM records incrementally with filter[updated_since].

Lists that can grow come back one page at a time. Storefront lists use numbered pages. Merchant lists, such as CRM contacts and orders, use cursors, which stay correct while records are being added and changed.

## Numbered pages [#numbered-pages]

Storefront lists take two query parameters:

| Parameter | Default | Range |
|---|---|---|
| `perPage` | 24 | 1 to 100. Larger values are capped at 100. |
| `page` | 1 | 1 and up |

They apply to `GET /v1/products`, `GET /v1/collections`, `GET /v1/collections/{slug}/products`, `GET /v1/search` and a signed-in customer's `GET /v1/orders`. Note the camel case: `perPage`, not `per_page`.

```bash
curl -G https://api.elaraone.app/api/v1/products \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  --data-urlencode "perPage=48" \
  --data-urlencode "page=2"
```

The response carries `links` and `meta` next to `data`:

```json title="Response (trimmed)"
{
  "data": [ … ],
  "links": {
    "first": "https://api.elaraone.app/api/v1/products?perPage=48&page=1",
    "last": "https://api.elaraone.app/api/v1/products?perPage=48&page=3",
    "prev": "https://api.elaraone.app/api/v1/products?perPage=48&page=1",
    "next": "https://api.elaraone.app/api/v1/products?perPage=48&page=3"
  },
  "meta": {
    "current_page": 2,
    "from": 49,
    "last_page": 3,
    "per_page": 48,
    "to": 96,
    "total": 131,
    "path": "https://api.elaraone.app/api/v1/products"
  }
}
```

- `links.next` is `null` on the last page, and `links.prev` on the first.
- `meta.total` and `meta.last_page` are what you need for page numbers.
- The links keep your other query parameters, such as filters and sorts.
- A page past the end returns an empty `data` list, not an error.

Lists that are always short are not paginated: `GET /v1/promotions`, `GET /v1/countries`, `GET /v1/cart/shipping-options` and `GET /v1/account/addresses` return everything at once.

## Cursors on merchant lists [#cursors-on-merchant-lists]

The merchant CRM lists (contacts, companies, tasks and their related records) and the merchant orders list use cursor pagination:

| Parameter | Meaning |
|---|---|
| `perPage` | 1 to 100, default 24. A value outside the range returns `422`. |
| `cursor` | `meta.next_cursor` or `meta.prev_cursor` from the previous page |
| `sort` | A sort the list allows, with `-` for descending |

```json title="Response (trimmed)"
{
  "data": [ … ],
  "links": { "first": null, "last": null, "prev": null, "next": "https://api.elaraone.app/api/v1/merchant/crm/contacts?cursor=eyJ…" },
  "meta": {
    "path": "https://api.elaraone.app/api/v1/merchant/crm/contacts",
    "per_page": 24,
    "next_cursor": "eyJ…",
    "prev_cursor": null
  }
}
```

Keep requesting with `cursor` set to `meta.next_cursor` until it is `null`. Treat cursors as opaque: pass them back exactly as you received them. Cursor lists have no `total`.

An unknown `filter[…]` or `sort` on these lists returns `400`; a malformed value, such as a date that cannot be read, returns `422 VALIDATION_FAILED`.

## Incremental sync with `filter[updated_since]` [#incremental-sync-with-filterupdated_since]

To keep another system in step with the store's CRM, do not re-read everything on every run. The CRM contacts, companies and tasks lists take `filter[updated_since]`, an ISO 8601 time, and return only records changed at or after it.

<Steps>
<Step>

### Run a full sync once [#run-a-full-sync-once]

Page through the whole list with `sort=updated_at` and store each record. Note the time you started.

</Step>
<Step>

### Then fetch only what changed [#then-fetch-only-what-changed]

On each later run, ask for records changed since the start of the previous run, and page through them the same way.

</Step>
<Step>

### Move the mark forward [#move-the-mark-forward]

When the run finishes, save its start time as the next run's `updated_since`.

</Step>
</Steps>

```bash tab="cURL"
curl -G https://api.elaraone.app/api/v1/merchant/crm/contacts \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "Authorization: Bearer $ELARAONE_MERCHANT_API_KEY" \
  --data-urlencode "filter[updated_since]=2026-10-23T02:00:00Z" \
  --data-urlencode "sort=updated_at" \
  --data-urlencode "perPage=100"
```

```ts tab="TypeScript"
async function* changedContacts(since: string) {
  let cursor: string | null = null
  do {
    const params = new URLSearchParams({ 'filter[updated_since]': since, sort: 'updated_at', perPage: '100' })
    if (cursor) params.set('cursor', cursor)

    const res = await fetch(`https://api.elaraone.app/api/v1/merchant/crm/contacts?${params}`, {
      headers: {
        'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
        'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
        Authorization: `Bearer ${process.env.ELARAONE_MERCHANT_API_KEY}`,
      },
    })
    const body = await res.json()
    yield* body.data
    cursor = body.meta.next_cursor
  } while (cursor)
}
```

- Use the start of the previous run, not its end, so a record changed while the run was in progress is picked up next time. You may see a few records twice; make your writes idempotent by record id.
- A tag change counts as a change to the contact.
- Pair the sync with CRM change webhooks if you want to hear about changes as they happen, and keep the sync as a safety net.
- Stay inside the store's API budget: see [rate limits](/docs/concepts/rate-limits).
