Quickstart
This page takes you from an empty terminal to a day of real sales data in about five minutes: list the shops your key can see, pull their transactions, then turn that into a repeating sync.
Before you start
Section titled “Before you start”You need three things:
- An API key from ScanFood — see step 1.
- Somewhere to run the calls from. A server, a scheduled job or a backend service. Not a browser, and not a mobile app: cross-origin requests are refused, and a key that reaches a user’s device has leaked.
- A way to keep the key secret — an environment variable, a secrets manager, anything but source control.
export SCANFOOD_API_KEY="sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"Step 1 — Get an API key
Section titled “Step 1 — Get an API key”API keys are issued by the ScanFood team. Contact us with:
- Which shops the key is for — a single shop, or a whole franchise.
- What you are building, so we can set a sensible daily quota.
- Whether you need member phone numbers or e-mail addresses. These are off by default and are only enabled after a conversation about data protection.
You will receive one string that looks like this:
sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXStep 2 — Call the API
Section titled “Step 2 — Call the API”Start with GET /v1/shops. It takes no parameters at all: the key itself
decides which branches come back, so this call also tells you exactly what your
key can see.
curl -s https://api.scanfood.co/ext/v1/shops \ -H "Authorization: Bearer $SCANFOOD_API_KEY"const BASE = 'https://api.scanfood.co/ext/v1';const KEY = process.env.SCANFOOD_API_KEY;
const res = await fetch(`${BASE}/shops`, { headers: { Authorization: `Bearer ${KEY}` },});const body = await res.json();console.log(body.data);import osimport requests
BASE = "https://api.scanfood.co/ext/v1"HEADERS = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"}
r = requests.get(f"{BASE}/shops", headers=HEADERS, timeout=30)r.raise_for_status()print(r.json()["data"]){ "ok": true, "data": [ { "shopId": "aK3mP9xQ2bR7cT4dV6wY", "shopName": "Example Restaurant — Silom", "franchiseId": null, "providerId": "bL4nQ0yR3cS8dU5eW7xZ", "timezone": "Asia/Bangkok", "categories": [ { "id": "9369601f-fef5-4a61-bfb8-26f25eab3dad", "name": "Food", "level": 1, "parentId": null, "hidden": false } ] } ], "serverTime": "2026-09-09T13:45:12.310Z"}Keep the shopId — every transaction call needs one. Keep the providerId
too: that is the identifier the member endpoints use.
Step 3 — Read the response
Section titled “Step 3 — Read the response”Now pull the bills for a date range. from and to are calendar days in the
shop’s own time zone, written as YYYYMMDD with no dashes, and both ends are
included.
curl -s -G https://api.scanfood.co/ext/v1/transactions \ -H "Authorization: Bearer $SCANFOOD_API_KEY" \ --data-urlencode "shopId=aK3mP9xQ2bR7cT4dV6wY" \ --data-urlencode "from=20260901" \ --data-urlencode "to=20260901" \ --data-urlencode "limit=200"const params = new URLSearchParams({ shopId: 'aK3mP9xQ2bR7cT4dV6wY', from: '20260901', to: '20260901', limit: '200',});
const res = await fetch(`${BASE}/transactions?${params}`, { headers: { Authorization: `Bearer ${KEY}` },});const { data, nextCursor } = await res.json();console.log(data.length, 'bills', nextCursor ? '(more to come)' : '(complete)');params = { "shopId": "aK3mP9xQ2bR7cT4dV6wY", "from": "20260901", "to": "20260901", "limit": 200,}
r = requests.get(f"{BASE}/transactions", headers=HEADERS, params=params, timeout=30)r.raise_for_status()body = r.json()print(len(body["data"]), "bills", "more" if body["nextCursor"] else "complete")A shortened response — the full field list is in the transactions reference:
{ "ok": true, "data": [ { "id": "dN6pS2aT5eU0fW7gY9zB", "receiptNumber": "RC-001", "taxNumber": null, "timestamp": "2026-09-01T13:30:00.000+07:00", "businessDate": "2026-09-01", "billDate": "2026-09-01", "updatedAt": "2026-09-01T13:30:00.000+07:00", "shopId": "aK3mP9xQ2bR7cT4dV6wY", "channel": { "code": "dine_in", "name": "Table 3" }, "memberId": "m1_7W3mBnrKtP3ieg3Zhve_Cj4OvSWX7FYAYCfZ3GDHT54n7KuhPMylFlhZE71FJb-gnMnqFgL7RL7dsPATXUZNTVN_698LdRA", "items": [ { "id": "iS1uX7fY0jZ5kB2lD4eG", "name": "Drunken noodles", "qty": 3, "unitPrice": 137.55, "lineTotal": 412.65, "cancelled": false } ], "amounts": { "subtotal": 412.65, "discountTotal": 20, "vat": 0, "vatType": "Include", "tip": 0, "net": 392.65 }, "payments": [{ "name": "Cash", "amount": 400 }], "status": "completed" } ], "nextCursor": null, "serverTime": "2026-09-01T06:30:00.000Z"}Four things to notice, because they catch almost everyone once:
| In the response | What it means |
|---|---|
nextCursor |
null means you have the whole result. Anything else is the cursor for the next page — see Pagination & sync. |
billDate vs businessDate |
from/to filter on billDate, the plain calendar day. businessDate is the day the shop counts the money in. They differ for shops that close after midnight — details. |
amounts.net |
What the customer actually paid, tip included. Subtract amounts.tip before you call it shop revenue. |
status |
Voided bills are still returned, as "void". Exclude them yourself if your report should not count them. |
Step 4 — Poll on a schedule
Section titled “Step 4 — Poll on a schedule”Do not re-download the same days forever. Once you hold a first full copy, switch to asking only for what changed:
- Transactions — call with
updatedSince(an ISO-8601 timestamp) instead offrom/to, and pass theserverTimeof your previous successful run. - Members —
updatedSinceis not available yet, so re-read the brand in full with the cursor. Once a day is plenty.
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-01T06:30:00.000Z" \ --data-urlencode "limit=200"async function pull(shopId, since) { let cursor = null; const rows = []; do { const params = new URLSearchParams({ shopId, updatedSince: since, limit: '200' }); if (cursor) params.set('cursor', cursor); const res = await fetch(`${BASE}/transactions?${params}`, { headers: { Authorization: `Bearer ${KEY}` }, }); const body = await res.json(); if (!body.ok) throw new Error(body.error); rows.push(...body.data); cursor = body.nextCursor; } while (cursor); return rows;}def pull(shop_id, since): rows, cursor = [], None while True: params = {"shopId": shop_id, "updatedSince": since, "limit": 200} if cursor: params["cursor"] = cursor r = requests.get(f"{BASE}/transactions", headers=HEADERS, params=params, timeout=30) r.raise_for_status() body = r.json() rows.extend(body["data"]) cursor = body["nextCursor"] if not cursor: return rowsTroubleshooting
Section titled “Troubleshooting”| What you see | What it usually is | Fix |
|---|---|---|
401 invalid_key |
Missing or malformed Authorization header, or a key that is not a production key. The same answer is given for an unknown key and a wrong secret, on purpose. |
Send Authorization: Bearer sf_live_…. The key goes in the header only — never in the query string or the body. |
401 key_revoked |
The key was revoked. | Ask for a new one; revocation cannot be undone. |
403 shop_not_in_scope |
The shopId is real but outside this key’s reach. |
Call GET /v1/shops and use one of the ids it returns. |
403 provider_not_in_scope |
Same, for providerId on the member endpoints. |
Take providerId from GET /v1/shops. |
400 bad_query — choose exactly one mode |
You sent both from/to and updatedSince, or neither. |
Pick one mode per request. |
400 bad_query — from/to must be YYYYMMDD |
You sent 2026-09-01. |
Requests use YYYYMMDD with no dashes; only responses use dashes. |
400 bad_query — range too wide |
More than 31 days between from and to. |
Split the backfill into windows of 31 days or fewer. |
429 rate_limited |
Too many requests in one minute. | Wait for the Retry-After header, then retry. |
429 quota_exceeded |
The key’s daily quota is used up; it resets at midnight Thailand time. | Poll less often, or ask for a higher quota. |
503 auth_unavailable |
Our key check is temporarily unavailable. Your key is fine. | Retry with backoff; do not re-issue the key. |
500 internal |
A transient failure on our side. | Retry with backoff; report it if it persists. |
| Nothing at all, from a web page | Browser calls are refused on purpose. | Call from your server. |
Every code we return, and what to do about it, is on the Errors page.