Go-live checklist
Work down the list. Every item is something that has a wrong answer you can still fix cheaply today, and an expensive one to discover during service hours.
Tick nothing you have not actually run.
Before you switch
Section titled “Before you switch”- The key lives in a secret manager or an injected environment variable — not in the repository, not in a config file you commit, not in a shared document.
- The key is used only from your server. Cross-origin requests are refused on purpose, so a browser or mobile app cannot call this API directly — and should never hold a key that reads a shop’s revenue.
- The key is never put in a URL, a query string or a request body.
Authorization: Bearer <key>is the only place we read it from. - Each integration has its own key with its own label, so one can be revoked without stopping the others.
- The key’s scope is the narrowest that does the job: a single-shop key if you integrate one branch, a franchise key only if you genuinely need every branch.
- You call
https://api.scanfood.co/ext/v1/…and nothing else. No other host is supported, and any host you were given earlier during development is not a fallback. - Someone other than the original developer knows where the key is stored and who to contact to have it replaced.
Correctness checks
Section titled “Correctness checks”- Dates go out without dashes and come back with them. Requests take
from=20260901; responses carrybillDate: "2026-09-01". Do not feed one format into the other. - Everything is Thailand time (ICT).
from/toare calendar days in the shop’s timezone,businessDateandbillDateare wall-clock days, and the daily quota resets at 00:00 ICT.serverTimeis the one value in UTC — it ends inZ. - You group by
businessDate, notbillDate. For a shop that closes after midnight, a 2 a.m. bill belongs to the previous trading day. Filtering happens onbillDate; reconciliation happens onbusinessDate, which every row carries for exactly this reason. - Date ranges are chunked to 31 days or fewer. A wider range is rejected with
400 bad_query; it does not silently truncate. - You page until
nextCursorisnull, passing the previousnextCursorback ascursor. A full last page can hand you a cursor whose next page is empty — that is normal, not a bug. - A dead cursor is handled. If the row a cursor points at is gone, you get
400 bad_cursor; restart that page without a cursor rather than aborting the run. - Bills: backfill with
from/tofirst, then switch toupdatedSince. Bills closed before change-tracking existed have noupdatedAtand never appear in the incremental mode. Incremental sync on top of an empty history means permanently missing bills. - Members: full sync only.
updatedSinceon/v1/membersreturns400 not_supportedby design. Page through withcursorand reconcile on your side. - Syncs are idempotent. Every endpoint is a
GET, so re-running a window is safe — provided your writer upserts onid(bills) ormemberId(members) instead of inserting blindly. - You branch on
error, not onmessage. Theerrorcode is the contract; message wording can change without notice. - Voided bills are handled.
statusiscompletedorvoid; a voided bill keeps its row and gains avoidReason. There is no separate refund object — a refund at the counter is a voided bill. - Discounts are read at bill level.
amounts.discount,campaign,couponanddiscountTotaldescribe the bill; line items carry no per-line discount. - Unknown fields are ignored, not rejected. New fields can appear in a response at any time; a strict parser that throws on them will break on a release that breaks nobody else.
Reliability checks
Section titled “Reliability checks”- Retries use exponential backoff and honour
Retry-After. That header appears on429 rate_limitedand tells you exactly how long to wait. Sleeping a fixed second and hammering again is how a minute-long pause becomes an hour-long one. -
429 quota_exceededis treated differently from429 rate_limited. The daily quota does not recover by waiting a few seconds — it resets at 00:00 ICT. Stop the run, alert someone, resume tomorrow or ask for a higher quota. -
503 auth_unavailableis retried, not diagnosed as a bad key. It means our key check is temporarily unavailable — your key is fine. Retry with backoff; do not have someone re-issue a key at 2 a.m. for nothing. -
500 internalis retried a few times, then reported. It is our side, and it is usually transient. -
401stops the run and alerts a human. A revoked or mistyped key will never fix itself by retrying, and a tight retry loop on401just burns your daily quota. - Every request has a timeout and a bounded retry count. Unbounded retries plus a rate limit is a self-inflicted outage.
- You log
X-RateLimit-Remainingand alert while there is still headroom — not at zero. - You know that failed requests count too. Both meters count requests received, not requests that succeeded, so a retry storm spends the same quota as real work.
- You do not rely on the per-minute limit being exact. It is enforced per running instance and it fails open if its own machinery is unavailable — so it can let a little more through than the number suggests. Treat it as a brake, never as your pacing mechanism.
const BASE = 'https://api.scanfood.co/ext/v1';
async function apiGet(path, { attempts = 5 } = {}) { for (let attempt = 1; ; attempt++) { const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${process.env.SCANFOOD_API_KEY}` }, signal: AbortSignal.timeout(30_000), });
if (res.ok) return res.json(); const body = await res.json().catch(() => ({}));
// Never retry: the answer will not change without a human. if (res.status === 401 || res.status === 403 || res.status === 400) { throw new Error(`${res.status} ${body.error}: ${body.message}`); } // Daily quota is gone until midnight ICT — stop, do not spin. if (body.error === 'quota_exceeded') { throw new Error('daily quota exhausted; resumes at 00:00 ICT'); } if (attempt >= attempts) throw new Error(`gave up after ${attempts}: ${body.error}`);
const retryAfter = Number(res.headers.get('Retry-After')); const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 // the server told us; obey it : Math.min(2 ** attempt * 500, 30_000); // otherwise back off await new Promise((r) => setTimeout(r, waitMs + Math.random() * 250)); }}import os, random, time, requests
BASE = "https://api.scanfood.co/ext/v1"SESSION = requests.Session()SESSION.headers["Authorization"] = f"Bearer {os.environ['SCANFOOD_API_KEY']}"
NO_RETRY = {400, 401, 403}
def api_get(path, attempts=5): for attempt in range(1, attempts + 1): res = SESSION.get(BASE + path, timeout=30) if res.ok: return res.json()
body = res.json() if res.headers.get("content-type", "").startswith("application/json") else {} if res.status_code in NO_RETRY: raise RuntimeError(f"{res.status_code} {body.get('error')}: {body.get('message')}") if body.get("error") == "quota_exceeded": raise RuntimeError("daily quota exhausted; resumes at 00:00 ICT") if attempt == attempts: raise RuntimeError(f"gave up after {attempts}: {body.get('error')}")
retry_after = res.headers.get("Retry-After") wait = int(retry_after) if retry_after and retry_after.isdigit() else min(2 ** attempt * 0.5, 30) time.sleep(wait + random.random() * 0.25)# See what a rate-limited response actually looks like, headers and all.curl -s -D - -o /dev/null \ -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/shops"
# HTTP/2 429# retry-after: 37# {"ok":false,"error":"rate_limited","message":"Too many requests, retry in about 37 seconds","retryAfterSec":37,"limit":60,"windowSec":60}Operational checks
Section titled “Operational checks”- Every request you make is logged on your side with: the path, the query, the HTTP status, the
errorcode if any, the number of rows returned, and the time. When something is missing three weeks from now, this is the only record that ties your run to ours. - Bill
idandmemberIdare stored with every row you import. They are the stable handles for/v1/transactions/{id}and/v1/members/{memberToken}, and the only way to ask us about one specific record. - You can quote a
keyIdand an ICT timestamp when you contact us. Our usage log is keyed on the key, the path and the time; thekeyIdis the middle segment ofsf_live_<keyId>_<secret>and is safe to share. The secret is not. - A run that returns zero rows raises a flag. An empty response is legitimate — a closed branch, a quiet night — but a sync that silently returns nothing for three days is not something to discover from a monthly report.
- Someone gets alerted when the schedule stops running, not only when it fails loudly.
- You have confirmed revocation works end to end. Ask us to revoke the key you used for exploration, then watch your own integration: within five minutes it must start seeing
401 key_revokedand must escalate rather than retry forever. - You know your own limits. Confirm the per-minute and per-day numbers issued for your key, and size your schedule against those rather than the defaults on this site.
After you switch
Section titled “After you switch”- Run the first scheduled cycle while someone is watching, not overnight.
- Reconcile one full trading day against the shop’s own end-of-day report — grouped by
businessDate— before you trust the pipeline. - Check
X-RateLimit-Remainingat the end of the first full day; that number tells you how much room you have to increase frequency later. - Have the exploration key revoked once the production key is proven.
- Keep an eye on the changelog — new fields land there, and it is the page that tells you when something you rely on is changing.