# Pagination

Cursor paging, the sync pattern, and why sorting is limited.

Lists are cursor-paginated. You ask for a page size and a position; you get
rows and a cursor for the next page.

```json
{
  "data": ["…25 objects…"],
  "meta": {
    "has_more": true,
    "next_cursor": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
  }
}
```

## Parameters

| Parameter        | Type                          | Default       | Notes                                                                                               |
| ---------------- | ----------------------------- | ------------- | --------------------------------------------------------------------------------------------------- |
| `limit`          | integer                       | `25`          | Maximum `100`. Values outside the range are **clamped**, not rejected — `limit=5000` gives you 100. |
| `starting_after` | object id                     | –             | Return the page *after* this object. Pass the previous page's `next_cursor`.                        |
| `ending_before`  | object id                     | –             | Return the page *before* this object. Paging backwards.                                             |
| `sort`           | `created_at` or `-created_at` | `-created_at` | Newest first by default.                                                                            |

`starting_after` and `ending_before` are mutually exclusive — sending both is a
`VALIDATION_ERROR` rather than a silently ignored parameter.

## The `meta` block

| Field         | Type             | Meaning                                                                            |
| ------------- | ---------------- | ---------------------------------------------------------------------------------- |
| `has_more`    | boolean          | There is at least one more row after this page.                                    |
| `next_cursor` | string or `null` | The id to pass as `starting_after` for the next page. **`null` on the last page.** |

`next_cursor` is deliberately `null` rather than the last row's id when the
list is exhausted, so `while (cursor)` terminates on its own and nobody has to
remember to check `has_more` as well.

## Walking every page

**cURL**

```bash
# Page 1 — no cursor.
curl -sS "https://api.salafems.com/ext/v1/orders?limit=100" \
  -H "Authorization: Bearer $SALAF_API_KEY"

# The response ends with:
#   "meta": { "has_more": true, "next_cursor": "0f0b…c31" }
# Feed that cursor back as starting_after, and repeat until
# has_more is false.
curl -sS "https://api.salafems.com/ext/v1/orders?limit=100&starting_after=0f0b…c31" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
async function* everyOrder(params = {}) {
  let cursor = null;

  do {
    const url = new URL("https://api.salafems.com/ext/v1/orders");
    url.searchParams.set("limit", "100");
    for (const [key, value] of Object.entries(params)) {
      url.searchParams.set(key, value);
    }
    if (cursor) {
      url.searchParams.set("starting_after", cursor);
    }

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` },
    });
    if (!response.ok) {
      const { error } = await response.json();
      throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
    }

    const { data, meta } = await response.json();
    yield* data;

    // next_cursor is null on the last page, which ends the loop.
    cursor = meta.has_more ? meta.next_cursor : null;
  } while (cursor);
}

for await (const order of everyOrder({ status: "confirmed" })) {
  console.log(order.number, order.total_amount);
}
```

**Python**

```python
import os

import requests


def every_order(**params):
    cursor = None
    headers = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"}

    while True:
        query = {"limit": 100, **params}
        if cursor:
            query["starting_after"] = cursor

        response = requests.get(
            "https://api.salafems.com/ext/v1/orders",
            params=query,
            headers=headers,
            timeout=30,
        )
        response.raise_for_status()
        page = response.json()

        yield from page["data"]

        if not page["meta"]["has_more"]:
            return
        cursor = page["meta"]["next_cursor"]


for order in every_order(status="confirmed"):
    print(order["number"], order["total_amount"])
```

**PHP**

```php
<?php

function every_order(array $params = []): Generator
{
    $key = getenv('SALAF_API_KEY');
    $cursor = null;

    do {
        $query = array_merge(['limit' => 100], $params);
        if ($cursor !== null) {
            $query['starting_after'] = $cursor;
        }

        $ch = curl_init('https://api.salafems.com/ext/v1/orders?' . http_build_query($query));
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
            CURLOPT_TIMEOUT => 30,
        ]);
        $page = json_decode(curl_exec($ch), true);
        curl_close($ch);

        yield from $page['data'];

        $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null;
    } while ($cursor !== null);
}

foreach (every_order(['status' => 'confirmed']) as $order) {
    echo $order['number'] . ' ' . $order['total_amount'] . PHP_EOL;
}
```

## Why cursors instead of page numbers

Because the primary consumer of this API is a sync, and a store keeps taking
orders while your sync runs.

With offset pages, one order placed while you are between page 1 and page 2
shifts every row down by one. Page 2 then re-serves a row you already had and
**skips one entirely** — silently. The merchant discovers it weeks later as a
missing order in their accounts.

A cursor is anchored to a row, not to a count. New rows arriving at the head do
not move the rows behind it, so the walk stays correct no matter what the store
does while you are reading. Deep pages are cheap for the same reason — there is
no `OFFSET 40000` for the database to count through.

## Why you can only sort by `created_at`

This is the constraint people ask about most, so here is the actual reason.

A cursor works by remembering *where you were* in a sorted order. That only
holds if the sort key does not move. `created_at` never changes after a row is
written, so a cursor over it is stable for as long as you care to page.

`updated_at` moves constantly — every status change, every payment, every edit.
Paging over it would mean a row you already read gets touched, jumps ahead of
your cursor, and is served to you again while another row slips behind it and
is never served at all. That is a data-loss bug wearing a feature's clothes, so
the sort allow-list contains `created_at` and nothing else.

> **Note — What to use instead**
>
> You almost certainly wanted `updated_after`, not `sort=updated_at`. It gives
> you exactly what you were reaching for — everything that changed — without
> the instability, because it **filters** on the moving column while still
> **paging** on the stable one.

## Syncing changes with `updated_after`

`updated_after` is the workhorse of any ongoing integration. It returns
everything modified since an instant — including rows created long before it.

An order placed last month and confirmed this morning is precisely the row a
`created_after` poll misses and an `updated_after` poll catches.

**cURL**

```bash
# Everything that changed since the last successful sync.
# Store the timestamp you STARTED the run at, not the one you finished at.
SINCE="2026-08-10T00:00:00Z"

curl -sS "https://api.salafems.com/ext/v1/orders?updated_after=$SINCE&limit=100&expand=items,customer" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
// Read the watermark BEFORE the run: anything modified while the run is
// in flight is then picked up next time instead of being skipped.
const runStartedAt = new Date().toISOString();
const since = await loadWatermark(); // your storage

let cursor = null;
do {
  const url = new URL("https://api.salafems.com/ext/v1/orders");
  url.searchParams.set("updated_after", since);
  url.searchParams.set("limit", "100");
  url.searchParams.set("expand", "items,customer");
  if (cursor) {
    url.searchParams.set("starting_after", cursor);
  }

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.SALAF_API_KEY}` },
  });
  const { data, meta } = await response.json();

  for (const order of data) {
    // Upsert on id — an order can appear in several runs as it moves
    // through its statuses, and the newest snapshot always wins.
    await upsertOrder(order);
  }

  cursor = meta.has_more ? meta.next_cursor : null;
} while (cursor);

await saveWatermark(runStartedAt);
```

**Python**

```python
import os
from datetime import datetime, timezone

import requests

# Read the watermark BEFORE the run, so rows changed mid-run are caught
# by the next one rather than skipped by this one.
run_started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
since = load_watermark()  # your storage

headers = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"}
cursor = None

while True:
    query = {
        "updated_after": since,
        "limit": 100,
        "expand": "items,customer",
    }
    if cursor:
        query["starting_after"] = cursor

    page = requests.get(
        "https://api.salafems.com/ext/v1/orders",
        params=query,
        headers=headers,
        timeout=30,
    ).json()

    for order in page["data"]:
        # Upsert on id: the same order reappears as its status changes.
        upsert_order(order)

    if not page["meta"]["has_more"]:
        break
    cursor = page["meta"]["next_cursor"]

save_watermark(run_started_at)
```

**PHP**

```php
<?php

// Read the watermark BEFORE the run — rows that change mid-run are then
// caught by the next run instead of falling through the gap.
$runStartedAt = gmdate('Y-m-d\TH:i:s\Z');
$since = load_watermark();

$key = getenv('SALAF_API_KEY');
$cursor = null;

do {
    $query = [
        'updated_after' => $since,
        'limit' => 100,
        'expand' => 'items,customer',
    ];
    if ($cursor !== null) {
        $query['starting_after'] = $cursor;
    }

    $ch = curl_init('https://api.salafems.com/ext/v1/orders?' . http_build_query($query));
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
        CURLOPT_TIMEOUT => 30,
    ]);
    $page = json_decode(curl_exec($ch), true);
    curl_close($ch);

    foreach ($page['data'] as $order) {
        upsert_order($order); // upsert on id
    }

    $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null;
} while ($cursor !== null);

save_watermark($runStartedAt);
```

Three rules make an `updated_after` sync correct:

1. **Record the watermark before the run, not after.** Anything modified while
   your run is in flight then gets picked up by the next run rather than
   falling into the gap between them.
2. **Upsert on `id`.** The same order legitimately appears in many runs as it
   moves through its statuses. The newest snapshot always wins.
3. **Overlap slightly.** Subtracting a minute from your stored watermark costs
   a few duplicate rows — which rule 2 already handles — and protects you from
   clock skew.

> **Warning — Not every resource has updated\_after yet**
>
> Today it exists on **orders** and **products** only.
>
> Customers, categories, brands, inventory levels, movements, locations and
> channels do not have it. For those, re-read the full list on a schedule —
> they are small and change rarely, so a nightly full read is entirely
> reasonable. The filter is planned for the remaining resources; it will be
> additive, and announced in the [changelog](https://www.salafems.com/developers/changelog.md).

[Webhooks](https://www.salafems.com/developers/webhooks.md) let you stop polling *frequently*, but not
stop polling: they are at-least-once and your receiver can be down, so a
periodic `updated_after` pass stays the reconciliation path. Poll hourly or
daily instead of every minute, and let events carry the urgency.

## Filtering and search

Filters are an explicit allow-list per resource, not a generic query language.
That is a deliberate limit: a generic filter syntax would expose our internal
schema as a public contract and freeze it there. The exact set for each
endpoint is in the [reference](https://www.salafems.com/developers/reference.md).

- **Orders** — `status`, `payment_status`, `fulfillment_status`, `channel`
  (the slug, not the id), `created_after`, `created_before`, `updated_after`,
  `search`
- **Products** — `status`, `category_id`, `updated_after`, `search`
- **Categories** and **Brands** — `status`
- **Inventory levels** — `warehouse_id`, `variant_id`, `low_stock`
- **Inventory movements** — `warehouse_id`, `variant_id`

`search` matches order number, customer name and customer phone on orders; name
and SKU on products. It is only offered where an index already supports it.

## Expanding related objects

Orders are compact by default — related objects are referenced by id. Ask for
them with `expand`:

```bash
curl -sS "https://api.salafems.com/ext/v1/orders?expand=customer,items&limit=50" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

`expand` accepts `customer` and `items`, comma-separated, one level deep — no
nesting. It is opt-in rather than always-on because a sync walking ten thousand
orders should not pay for line items it is going to throw away.

## The last page

```json
{
  "data": ["…7 objects…"],
  "meta": {
    "has_more": false,
    "next_cursor": null
  }
}
```

An empty list is `data: []` with `has_more: false` — not a `404`. A search that
matches nothing is a successful request with no results.
