Skip to content

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.

  • 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.
  • Dates go out without dashes and come back with them. Requests take from=20260901; responses carry billDate: "2026-09-01". Do not feed one format into the other.
  • Everything is Thailand time (ICT). from/to are calendar days in the shop’s timezone, businessDate and billDate are wall-clock days, and the daily quota resets at 00:00 ICT. serverTime is the one value in UTC — it ends in Z.
  • You group by businessDate, not billDate. For a shop that closes after midnight, a 2 a.m. bill belongs to the previous trading day. Filtering happens on billDate; reconciliation happens on businessDate, 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 nextCursor is null, passing the previous nextCursor back as cursor. 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/to first, then switch to updatedSince. Bills closed before change-tracking existed have no updatedAt and never appear in the incremental mode. Incremental sync on top of an empty history means permanently missing bills.
  • Members: full sync only. updatedSince on /v1/members returns 400 not_supported by design. Page through with cursor and reconcile on your side.
  • Syncs are idempotent. Every endpoint is a GET, so re-running a window is safe — provided your writer upserts on id (bills) or memberId (members) instead of inserting blindly.
  • You branch on error, not on message. The error code is the contract; message wording can change without notice.
  • Voided bills are handled. status is completed or void; a voided bill keeps its row and gains a voidReason. There is no separate refund object — a refund at the counter is a voided bill.
  • Discounts are read at bill level. amounts.discount, campaign, coupon and discountTotal describe 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.
  • Retries use exponential backoff and honour Retry-After. That header appears on 429 rate_limited and 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_exceeded is treated differently from 429 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_unavailable is 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 internal is retried a few times, then reported. It is our side, and it is usually transient.
  • 401 stops the run and alerts a human. A revoked or mistyped key will never fix itself by retrying, and a tight retry loop on 401 just 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-Remaining and 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));
}
}
  • Every request you make is logged on your side with: the path, the query, the HTTP status, the error code 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 id and memberId are 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 keyId and an ICT timestamp when you contact us. Our usage log is keyed on the key, the path and the time; the keyId is the middle segment of sf_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_revoked and 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.
  • 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-Remaining at 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.