# Go-live checklist

> Everything to confirm before you point your integration at a real shop on a schedule.

import { Tabs, TabItem } from '@astrojs/starlight/components';

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

- [ ] 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

- [ ] **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.

## Reliability checks

- [ ] **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.

<Tabs syncKey="lang">
<TabItem label="JavaScript">
```js
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));
  }
}
```
</TabItem>
<TabItem label="Python">
```python
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)
```
</TabItem>
<TabItem label="curl">
```bash
# 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}
```
</TabItem>
</Tabs>

## Operational checks

- [ ] **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.

## 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-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](/changelog/) — new fields land there, and it is the page that tells you when something you rely on is changing.

:::tip[The single most common failure]
Switching to `updatedSince` without a full backfill first. It looks like it
works — data flows, rows appear — and the bills that are missing are exactly the
old ones nobody looks at until the first month-end reconciliation.
:::
