SALAF EMSDevelopers
salafems.com

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

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.

# 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"

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.

# 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"

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.

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.

# 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"

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

FieldMeaning
on_handPhysically present. What a stock count would find.
reservedHeld for orders that have not shipped.
availableon_hand − reserved. Sell against this one.

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 page.

Adding webhooks later

Polling is the reconciliation path and should stay, but you do not have to poll often once webhooks are wired up:

Instead of polling forSubscribe to
New and changed ordersorder.created, order.updated, order.paid, order.cancelled
Stock movementinventory.updated (coalesced per variant per 10s), inventory.adjusted, inventory.transferred
Reorder alertsinventory.low_stock (edge-triggered, once per crossing)
Catalogue changesproduct.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.