Pagination & sync
List endpoints return one page at a time and hand you a cursor for the next. This page covers both halves of the job: reading a result set to the end, and keeping your own copy in step afterwards without downloading everything again.
Cursor pagination
Section titled “Cursor pagination”| Field | Direction | What it is |
|---|---|---|
limit |
Request | Rows per page. Default 100, maximum 200 |
cursor |
Request | The nextCursor from the previous page. Omit it for the first page |
nextCursor |
Response | Cursor for the next page, or null when the result is complete |
Rules that follow from that:
limitis corrected, not rejected. A value above 200 is reduced to 200, and a value that is not a positive number falls back to 100. You will not get a400for it, so check what you actually received.- A cursor is opaque. Store it as a string and send it back unchanged; never
parse it or build one yourself. On the member endpoints the cursor is a
m1_…token, and a raw id is rejected with400 bad_cursor. - “Full page” means “ask again”. The next page is offered whenever a page
came back completely full. If the last page happens to be exactly full you
will get one more request that returns
"data": []with"nextCursor": null. That is normal, not a bug. - There is no total. No
hasMore, no row count, nopageoroffset. The only stopping condition isnextCursor === null. GET /v1/shopsis not paginated at all — it returns every branch the key can read in a single response.
Reading every page
Section titled “Reading every page”The same loop works for transactions and for members; only the parameters differ.
# First pagecurl -s -G https://api.scanfood.co/ext/v1/transactions \ -H "Authorization: Bearer $SCANFOOD_API_KEY" \ --data-urlencode "shopId=aK3mP9xQ2bR7cT4dV6wY" \ --data-urlencode "from=20260801" --data-urlencode "to=20260831" \ --data-urlencode "limit=200"
# Next page: pass the nextCursor you just receivedcurl -s -G https://api.scanfood.co/ext/v1/transactions \ -H "Authorization: Bearer $SCANFOOD_API_KEY" \ --data-urlencode "shopId=aK3mP9xQ2bR7cT4dV6wY" \ --data-urlencode "from=20260801" --data-urlencode "to=20260831" \ --data-urlencode "limit=200" \ --data-urlencode "cursor=dN6pS2aT5eU0fW7gY9zB"const BASE = 'https://api.scanfood.co/ext/v1';
async function* pages(path, query) { let cursor = null; do { const params = new URLSearchParams({ ...query, limit: '200' }); if (cursor) params.set('cursor', cursor);
const res = await fetch(`${BASE}${path}?${params}`, { headers: { Authorization: `Bearer ${process.env.SCANFOOD_API_KEY}` }, }); const body = await res.json(); if (!body.ok) throw new Error(`${res.status} ${body.error}`);
yield body.data; cursor = body.nextCursor; } while (cursor);}
for await (const rows of pages('/transactions', { shopId: 'aK3mP9xQ2bR7cT4dV6wY', from: '20260801', to: '20260831',})) { await save(rows);}BASE = "https://api.scanfood.co/ext/v1"
def pages(path, query): cursor = None while True: params = {**query, "limit": 200} if cursor: params["cursor"] = cursor
r = requests.get(BASE + path, headers=HEADERS, params=params, timeout=30) r.raise_for_status() body = r.json()
yield body["data"] cursor = body.get("nextCursor") if not cursor: return
for rows in pages("/transactions", { "shopId": "aK3mP9xQ2bR7cT4dV6wY", "from": "20260801", "to": "20260831",}): save(rows)Keep every parameter identical across the pages of one result set. Changing the range or the limit halfway through gives you a cursor that belongs to a different query.
Incremental sync
Section titled “Incremental sync”Once you hold a full copy, ask only for what changed.
Transactions accept updatedSince, an ISO-8601 timestamp. Rows come back
ordered by the moment they last changed, so re-issued tax invoices, voided bills
and amended fields all reappear.
curl -s -G https://api.scanfood.co/ext/v1/transactions \ -H "Authorization: Bearer $SCANFOOD_API_KEY" \ --data-urlencode "shopId=aK3mP9xQ2bR7cT4dV6wY" \ --data-urlencode "updatedSince=2026-09-09T04:00:00.000Z" \ --data-urlencode "limit=200"Use the serverTime of your last successful run as the next updatedSince, and
only advance it once every page of the run has been stored. Overlapping by a
minute costs nothing — every row carries a stable id, so re-reading one is an
update, not a duplicate.
from/to and updatedSince are two modes of the same endpoint, and you must
choose exactly one per request. Sending both, or neither, is 400 bad_query.
Backfilling history
Section titled “Backfilling history”Do this once per shop, then switch to incremental.
- List the shops.
GET /v1/shopsgives you everyshopIdthe key can read. - Walk the calendar in windows of 31 days or fewer. A wider range is
rejected with
400 bad_query(range too wide). Month-sized windows are the easiest to reason about. - Page each window to the end with
limit=200, followingnextCursor. - Record the
serverTimeof the final successful window. That timestamp is the starting point for your first incremental run. - Members separately. Page
GET /v1/membersperproviderId— one brand may be shared by several branches, so de-duplicate the provider ids you got from step 1 before you start.
Idempotency on your side
Section titled “Idempotency on your side”The API guarantees nothing changes when you read; it is your storage that has to tolerate the same row arriving twice.
- Upsert on
idfor transactions, and onmemberIdfor members. Both are stable and never reused. - Handle
status: "void"as an update, not a delete. A bill you already stored can come back voided; keep the row and mark it, so your totals and the shop’s agree. - Store
updatedAtalongside each bill. If a row arrives with an olderupdatedAtthan the one you hold, it is a replay — ignore it. - Advance your watermark only after a successful run. Storing rows and the
new
updatedSincein the same transaction avoids a gap if the job dies halfway. - Expect
nullvalues, not missing fields. Fields that do not apply come back asnull, so a column that is suddenly empty is data, not a schema change.