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_after1. 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"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);
}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
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.
# 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"// 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);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
// 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:
- Watermark before the run, as above.
- Upsert on
id. The same order legitimately appears in many runs as it moves through its statuses; the newest snapshot wins. - 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"// 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);
}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
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. |
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_cursoris 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_moreis 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 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=100is 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-Remainingand slow down before it reaches zero. Backing off at 10 remaining is cheaper than a429and 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.