# Sync inventory to an ERP

Cursors, watermarks and checkpoints that survive a crash.

An ERP, an accounting package or a warehouse system needs its own copy of the
store's catalogue, stock and orders — and needs it to stay correct while the
store keeps trading. This is the shape that gets built, every time.

**You need** a key with `products:read`, `inventory:read`, `locations:read` and
usually `orders:read`. A store key unless head office genuinely reads several
stores at once.

## The shape

```text
once      full load          → page everything, record a watermark
then      incremental poll   → updated_after=<watermark>, upsert on id
alongside slow full re-read  → the small lists that have no updated_after
```

## 1. Full load

Page through with a cursor until the end. Ask for `limit=100` — the rate limit
counts *requests*, not rows, so a bigger page is free throughput.

**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;
}
```

Do this for `/products` and `/orders`, then for `/inventory/levels`.

**Record the timestamp you started at**, not the one you finished at. Anything
that changed while the load was running is then picked up by the first
incremental poll rather than falling into the gap between them.

## 2. Incremental polling

`updated_after` is the workhorse. It returns everything modified since an
instant — including rows created long before it, which is precisely what a
`created_after` poll misses.

**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 it correct:

1. **Watermark before the run**, as above.
2. **Upsert on `id`.** The same order legitimately appears in many runs as it
   moves through its statuses; the newest snapshot wins.
3. **Overlap slightly.** Subtracting a minute from your stored watermark costs
   a few duplicate rows — which rule 2 already absorbs — and protects you from
   clock skew between your machine and ours.

> **Warning — updated\_after exists on orders and products only**
>
> Customers, categories, brands, inventory levels, movements, locations and
> channels do not have it yet.
>
> For those, re-read the whole list on a slower schedule. They are small and
> change rarely — a nightly full pass is entirely reasonable, and cheaper than
> the machinery you would build to avoid it.

## 3. Stock levels

Levels are one row per variant per location, so this list is the one that grows
fastest. It has no `updated_after`, which makes a full paged pass the sync.

**cURL**

```bash
# Levels are one row per variant per location. Page with the cursor;
# there is no updated_after on this resource, so a full pass is the sync.
curl -sS "https://api.salafems.com/ext/v1/inventory/levels?limit=100" \
  -H "Authorization: Bearer $SALAF_API_KEY"

# Then follow meta.next_cursor until has_more is false:
curl -sS "https://api.salafems.com/ext/v1/inventory/levels?limit=100&starting_after=0f0b…c31" \
  -H "Authorization: Bearer $SALAF_API_KEY"

# Only what is running out, for a reorder report:
curl -sS "https://api.salafems.com/ext/v1/inventory/levels?low_stock=true&limit=100" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
// Checkpoint the CURSOR, not a row count: a crash at page 40 resumes at
// page 40 rather than re-reading everything from the top.
async function syncLevels(checkpoint) {
  let cursor = await checkpoint.load(); // null on a fresh run

  do {
    const url = new URL("https://api.salafems.com/ext/v1/inventory/levels");
    url.searchParams.set("limit", "100");
    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();

    // available = on_hand − reserved. Sell against available; report on
    // on_hand. Writing the wrong one into an ERP oversells the store.
    await erp.upsertStock(
      data.map((level) => ({
        sku: level.sku,
        warehouse: level.location.code,
        onHand: level.on_hand,
        available: level.available,
      })),
    );

    cursor = meta.has_more ? meta.next_cursor : null;
    await checkpoint.save(cursor);
  } while (cursor);
}
```

**Python**

```python
import os

import requests

HEADERS = {"Authorization": f"Bearer {os.environ['SALAF_API_KEY']}"}


def sync_levels(checkpoint):
    """Checkpoint the cursor so a crash resumes mid-walk, not from zero."""
    cursor = checkpoint.load()

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

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

        # available = on_hand - reserved. Sell against available.
        erp.upsert_stock(
            [
                {
                    "sku": level["sku"],
                    "warehouse": level["location"]["code"],
                    "on_hand": level["on_hand"],
                    "available": level["available"],
                }
                for level in page["data"]
            ]
        )

        if not page["meta"]["has_more"]:
            checkpoint.save(None)
            return

        cursor = page["meta"]["next_cursor"]
        checkpoint.save(cursor)
```

**PHP**

```php
<?php

function sync_levels(Checkpoint $checkpoint): void
{
    $key = getenv('SALAF_API_KEY');
    $cursor = $checkpoint->load();

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

        $ch = curl_init('https://api.salafems.com/ext/v1/inventory/levels?' . 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 $level) {
            // available = on_hand - reserved.
            erp_upsert_stock(
                $level['sku'],
                $level['location']['code'],
                $level['on_hand'],
                $level['available'],
            );
        }

        $cursor = $page['meta']['has_more'] ? $page['meta']['next_cursor'] : null;
        $checkpoint->save($cursor);
    } while ($cursor !== null);
}
```

The three numbers, and which one to write into your ERP:

| Field       | Meaning                                            |
| ----------- | -------------------------------------------------- |
| `on_hand`   | Physically present. What a stock count would find. |
| `reserved`  | Held for orders that have not shipped.             |
| `available` | `on_hand − reserved`. **Sell against this one.**   |

> **Danger — Writing on\_hand where you meant available oversells**
>
> A variant with 10 on hand and 8 reserved has 2 available. An ERP that
> publishes 10 as sellable will take eight orders it cannot fill, and the
> merchant finds out from customers.

`low_stock=true` narrows the list to variants at or below their threshold —
that is the reorder report, and it is far cheaper than fetching everything and
filtering client-side.

## 4. Checkpoint properly

The difference between a sync that survives an outage and one that starts over:

- **Checkpoint the cursor**, not a page number. `meta.next_cursor` is a
  position anchored to a row, so resuming from it is exact — even if the store
  took 200 orders while you were down.
- **Checkpoint after the page is committed** to your side, not after it is
  fetched. Crashing between fetch and commit should re-fetch, and it does.
- **Clear the cursor when `has_more` is false**, so the next run starts a fresh
  pass.

## Why there are no page numbers

Because the store keeps trading while your sync runs.

With offset pages, one order placed 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 rather than to a count, so new rows arriving at
the head do not move the rows behind them. Deep pages are cheap for the same
reason: there is no `OFFSET 40000` for the database to count through. Full
reasoning on the [Pagination](https://www.salafems.com/developers/pagination.md#why-cursors-instead-of-page-numbers)
page.

## Adding webhooks later

Polling is the reconciliation path and should stay, but you do not have to poll
*often* once [webhooks](https://www.salafems.com/developers/webhooks.md) are wired up:

| Instead of polling for | Subscribe to                                                                                       |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| New and changed orders | `order.created`, `order.updated`, `order.paid`, `order.cancelled`                                  |
| Stock movement         | `inventory.updated` (coalesced per variant per 10s), `inventory.adjusted`, `inventory.transferred` |
| Reorder alerts         | `inventory.low_stock` (edge-triggered, once per crossing)                                          |
| Catalogue changes      | `product.created`, `product.updated`, `product.archived`                                           |

Keep the poll as a daily or hourly safety net. Webhooks are at-least-once and
your receiver can be down; a periodic `updated_after` pass closes any gap
without anybody having to notice there was one.

## Scheduling and limits

- **120 reads per minute per key** by default. A full pass of 10,000 products
  at `limit=100` is 100 requests — under a minute of quota.
- **Do not poll tightly.** Once a minute is plenty for almost every
  integration; once every few minutes is plenty for most.
- **Split heavy work onto its own key.** Buckets are per key, so a nightly bulk
  load on one key cannot starve a real-time listener on another — and you can
  revoke one without touching the other.
- **Watch `X-RateLimit-Remaining`** and slow down before it reaches zero.
  Backing off at 10 remaining is cheaper than a `429` and a retry.

## Alerting

Log the `request_id` of every failure — it is the only handle that finds your
exact request in our logs.

Alert on `KEY_REVOKED` **specifically and loudly**: it means a human at the
merchant deliberately turned your integration off. Retrying forever, quietly,
is the wrong response to that.
