This is the full developer documentation for ScanFood API # ScanFood API Docs > Every bill your shops ring up, available to your own systems over HTTPS. The ScanFood API is a read-only HTTP interface to the data your shops already record: shops, closed transactions and loyalty members. Authenticate with one API key, poll on your own schedule, and keep your accounting, reporting or CRM systems in step with the point of sale. [Quickstart](/get-started/quickstart/)First authenticated call in five minutes. [Authentication](/get-started/authentication/)API keys, what each key can see, and how to rotate one. [Pagination & sync](/get-started/pagination-and-sync/)Walk the cursor, then keep a local copy up to date. [API reference](/api/)Every endpoint, generated from the spec. Three things worth knowing before you write any code: * **Server to server only.** Keys must never reach a browser or a mobile app, and requests from a browser are refused. See [Authentication](/get-started/authentication/). * **Filter on the calendar date, group by the business date.** A bill paid at 01:00 may belong to yesterday’s takings. See [Time zone & business date](/get-started/timezone-and-business-date/). * **Read is all there is.** Every endpoint is a `GET`; nothing you send can change what the POS recorded. See [Introduction](/get-started/introduction/). # Copy page > Grab any page as plain Markdown, from the button or from a URL — and import the spec straight into your API client. Every hand-written page on this site has a plain-Markdown twin. You can copy the page you are reading with one click, fetch it by URL from a script, or hand the whole endpoint contract to an API client — no key required for any of it. ## The Copy page button [Section titled “The Copy page button”](#the-copy-page-button) At the top of each guide, next to the breadcrumb, there is a **Copy page** button. It fetches that page’s Markdown and puts it on your clipboard; the label turns into **Copied** when it lands. Paste it into a chat with an assistant, a ticket, or a design document. What you get is the page’s own source: headings, tables, lists and every code sample, with a title and a one-line summary at the top. A few structural markers come through as written — tab groups and callouts keep their markup — which reads fine to a person and perfectly well to a model. Not on the API Reference Reference pages are generated from the OpenAPI document, so they have no Markdown twin and no button. For those, take the spec itself — see [Importing the spec](#importing-the-spec) below. ## Markdown URLs [Section titled “Markdown URLs”](#markdown-urls) The button is a convenience; the URL is the interface. Take the page’s path, put `/md` in front and `.md` on the end: | Page | Markdown | | ---------------------------------------- | -------------------------------------------------------------------------------------------- | | `/get-started/quickstart/` | [`/md/get-started/quickstart.md`](/md/get-started/quickstart.md) | | `/before-you-go-live/go-live-checklist/` | [`/md/before-you-go-live/go-live-checklist.md`](/md/before-you-go-live/go-live-checklist.md) | | `/` (the home page) | [`/md/index.md`](/md/index.md) | | `/th/get-started/quickstart/` | [`/md/th/get-started/quickstart.md`](/md/th/get-started/quickstart.md) | The Thai pages have twins too, at the same path under `th/`. * curl ```bash # One page curl -s https://api.scanfood.co/md/get-started/pagination-and-sync.md # Save a few pages next to your project for page in get-started/quickstart get-started/errors before-you-go-live/go-live-checklist; do curl -s --create-dirs -o "docs/$page.md" "https://api.scanfood.co/md/$page.md" done ``` * JavaScript ```js const page = async (slug) => (await fetch(`https://api.scanfood.co/md/${slug}.md`)).text(); const context = ( await Promise.all([ page('get-started/authentication'), page('get-started/rate-limits'), page('before-you-go-live/go-live-checklist'), ]) ).join('\n\n---\n\n'); ``` * Python ```python import requests def page(slug: str) -> str: res = requests.get(f"https://api.scanfood.co/md/{slug}.md", timeout=30) res.raise_for_status() return res.text context = "\n\n---\n\n".join( page(slug) for slug in ( "get-started/authentication", "get-started/rate-limits", "before-you-go-live/go-live-checklist", ) ) ``` Need everything at once rather than page by page? That is what [llms.txt](/ai/llms-txt/) is for. ## Importing the spec [Section titled “Importing the spec”](#importing-the-spec) The OpenAPI document behind the **API Reference** is public and needs no key: ```text https://api.scanfood.co/ext/v1/openapi.json ``` It is the same document the reference pages are generated from, which means an import gives you every endpoint, parameter and response field exactly as they are implemented. **Postman** — *Import* → *Link* → paste the URL → *Continue* → *Import*. You get a collection with all six endpoints. Then open the collection’s *Authorization* tab, choose **Bearer Token**, and set the token to your key — Postman will send `Authorization: Bearer …` on every request in the collection. **Insomnia** — *Import* → *URL* → paste the URL → *Scan* → *Import*. Do the same for the key: set a Bearer token on the request group so every request inherits it. **Anything else that reads OpenAPI 3.1** — tools that build client code, API gateways, schema-aware editors — the URL is all they need. Do not save the key into a shared collection Postman and Insomnia will happily store a token in a file that gets committed or synced to a team workspace. Put the key in an environment variable, or in a local environment that is not shared, and re-read [Security best practices](/before-you-go-live/security-best-practices/) before you export anything. ## Pasting into an assistant [Section titled “Pasting into an assistant”](#pasting-into-an-assistant) A prompt that works well: ```text Documentation for the API I am integrating with follows. Answer only from it; if it does not say, tell me it does not say. Question: how do I fetch every bill for one shop for last month without exceeding the 31-day range limit? ``` Two habits that save time: * **Give it the pages that matter, not everything.** Authentication, pagination and the endpoint you are working on beats the whole site — a smaller context gets a sharper answer. * **Add the spec when field names are involved.** The guides explain behaviour; `openapi.json` is the authority on exactly what a response contains. Assistants that only get the prose will confidently invent a field. Ask it for the failure paths too “What errors can this call return, and which of them should I retry?” is a better first question than “write the code” — and everything it needs to answer is on the [Errors](/get-started/errors/) page. # llms.txt > Machine-readable copies of this documentation, for coding assistants and AI agents. This site publishes itself as plain text, following the [llms.txt convention](https://llmstxt.org/). If you are building an integration with an AI assistant beside you, point it at one of these files instead of asking it to crawl the site — it gets the same words, without the navigation, the CSS and the guesswork. Nothing here needs an API key. All three files are public. ## What is published [Section titled “What is published”](#what-is-published) | File | What is in it | Roughly | | ------------------------------------ | ------------------------------------------------------------------------- | ---------- | | [`/llms.txt`](/llms.txt) | A short index that names the other two files and links to them. | Under 1 KB | | [`/llms-full.txt`](/llms-full.txt) | Every guide on this site, in full, as one Markdown document. | \~130 KB | | [`/llms-small.txt`](/llms-small.txt) | The same pages with the blank lines collapsed — same words, fewer tokens. | \~115 KB | Two things to know about the scope: * **English only.** The Thai pages under `/th/` are not included, so an assistant reading these files will not see the same page twice in two languages. * **Guides only.** The generated **API Reference** section is not part of these files, because it already has a machine-readable original: the OpenAPI document at `https://api.scanfood.co/ext/v1/openapi.json`. Give your assistant that for endpoint shapes, and `llms-full.txt` for everything else. ## llms.txt [Section titled “llms.txt”](#llmstxt) `https://api.scanfood.co/llms.txt` is the entry point. It is deliberately tiny — a title, one line about what this API is, and links to the two full documents: ```text # ScanFood API > Read-only HTTP API for transactions, shops and members recorded by ScanFood POS. ## Documentation Sets - [Abridged documentation](https://api.scanfood.co/llms-small.txt): a compact version … - [Complete documentation](https://api.scanfood.co/llms-full.txt): the full documentation … ``` Tools that support the convention read this file first and follow the link they need. If you are wiring something up by hand, skip it and fetch one of the two below directly. ## llms-full.txt and llms-small.txt [Section titled “llms-full.txt and llms-small.txt”](#llms-fulltxt-and-llms-smalltxt) Both contain the same set of pages, in the same order, starting with a one-line header that tells the model what it is reading. * **`llms-full.txt`** keeps the original formatting: headings, tables, lists and every code sample, with the paragraph breaks intact. Use it when the assistant has room and you want the text to read naturally. * **`llms-small.txt`** is the same content with the whitespace squeezed out. Use it when you are pasting into a context window you are trying not to fill — tables and code fences survive, the prose just runs together. Both are rebuilt every time this site is published, so they never drift from the pages you are reading. The spec is the authority on endpoints These files carry the guides — how to page, how to retry, how dates behave. When a guide and the OpenAPI document disagree about a parameter or a field, the OpenAPI document is right. Give your assistant both. ## How to use it [Section titled “How to use it”](#how-to-use-it) **In a coding assistant or an AI-enabled editor.** Add the URL as documentation context. Most tools accept a link and fetch it themselves: ```text https://api.scanfood.co/llms-full.txt ``` **In a prompt.** Fetch the file and paste it in, or hand your agent both sources at once: * curl ```bash # The whole documentation set as one file curl -s https://api.scanfood.co/llms-full.txt -o scanfood-docs.md # The compact variant, when context is tight curl -s https://api.scanfood.co/llms-small.txt -o scanfood-docs-small.md # The endpoint contract itself (public, no key needed) curl -s https://api.scanfood.co/ext/v1/openapi.json -o scanfood-openapi.json ``` * JavaScript ```js const [docs, spec] = await Promise.all([ fetch('https://api.scanfood.co/llms-small.txt').then((r) => r.text()), fetch('https://api.scanfood.co/ext/v1/openapi.json').then((r) => r.json()), ]); const prompt = [ 'You are helping me integrate with the ScanFood API.', 'Documentation follows. Treat the OpenAPI document as authoritative', 'for endpoints, parameters and response fields.', '--- GUIDES ---', docs, '--- OPENAPI ---', JSON.stringify(spec), ].join('\n'); ``` * Python ```python import json, requests docs = requests.get("https://api.scanfood.co/llms-small.txt", timeout=30).text spec = requests.get("https://api.scanfood.co/ext/v1/openapi.json", timeout=30).json() prompt = "\n".join([ "You are helping me integrate with the ScanFood API.", "Documentation follows. Treat the OpenAPI document as authoritative", "for endpoints, parameters and response fields.", "--- GUIDES ---", docs, "--- OPENAPI ---", json.dumps(spec), ]) ``` **For one page only.** If the question is about a single topic, a whole documentation set is overkill — every page here has a Markdown twin of its own. See [Copy page](/ai/copy-page/). Check what the assistant tells you Model answers age badly: limits change, fields are added, and an assistant that read these files last month will happily invent the rest. Anything about authentication, quotas or money is worth confirming against the page it came from. # Go-live checklist > Everything to confirm before you point your integration at a real shop on a schedule. 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”](#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 ` 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”](#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 [Section titled “Reliability checks”](#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. - 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)); } } ``` - 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) ``` - 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} ``` ## Operational checks [Section titled “Operational checks”](#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__` 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 [Section titled “After you switch”](#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. 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. # Security best practices > Where to keep the key, who should hold it, how to rotate it, and what to do if it leaks. An API key reads a shop’s sales history and its member list. It cannot change anything — every endpoint is read-only — so the risk it carries is disclosure, not damage. That still means the key deserves the same handling as a database password. Two facts shape everything on this page: * **The key is shown once.** We store only a one-way hash of it, so no one at ScanFood can read it back to you. Lost means replaced, not recovered. * **The key travels in one place only.** We read it from `Authorization: Bearer ` and nowhere else — not from a query string, not from a body. ## Store the key on a server [Section titled “Store the key on a server”](#store-the-key-on-a-server) Keep the key in a secret manager, or in an environment variable injected at deploy time. Never in the repository, never in a file you commit, never pasted into a ticket or a chat thread. * curl ```bash # Read it into the shell without leaving it in your history… read -rs SCANFOOD_API_KEY && export SCANFOOD_API_KEY curl -s -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/shops" # …and never like this: the key lands in shell history, in access logs, # and in every proxy between you and us. # curl "https://api.scanfood.co/ext/v1/shops?key=sf_live_…" ← the API ignores it anyway ``` * JavaScript ```js // Read from the environment. Do not hard-code, do not ship to the client. const KEY = process.env.SCANFOOD_API_KEY; if (!KEY) throw new Error('SCANFOOD_API_KEY is not set'); const res = await fetch('https://api.scanfood.co/ext/v1/shops', { headers: { Authorization: `Bearer ${KEY}` }, }); // Redact the key before anything is logged. const safe = (s) => String(s).replace(/sf_live_[a-z0-9]+_[a-f0-9]+/g, 'sf_live_***'); console.log(safe(`GET /v1/shops -> ${res.status}`)); ``` * Python ```python import os, re, requests KEY = os.environ.get("SCANFOOD_API_KEY") if not KEY: raise SystemExit("SCANFOOD_API_KEY is not set") res = requests.get( "https://api.scanfood.co/ext/v1/shops", headers={"Authorization": f"Bearer {KEY}"}, timeout=30, ) REDACT = re.compile(r"sf_live_[a-z0-9]+_[a-f0-9]+") print(REDACT.sub("sf_live_***", f"GET /v1/shops -> {res.status_code}")) ``` Never put the key in a browser or a mobile app Cross-origin requests to this API are refused on purpose, so a web page cannot call it directly. That refusal is a feature: if a page of yours is being blocked, it means a key is sitting somewhere every visitor could read it. Move the call to your server. Also worth knowing: * **Traffic is HTTPS only.** Do not disable certificate verification “just for testing” — that is exactly the request that ends up running in production. * **We never log the key.** Neither the full key nor its hash appears in our usage records. What we do keep is the `keyId` — the middle segment, safe to quote in a support conversation. ## One key per integration [Section titled “One key per integration”](#one-key-per-integration) Ask for a separate key for every system that calls us: the nightly accounting sync, the dashboard, the analyst’s notebook, the vendor doing a proof of concept. | Why | What it buys you | | ----------------------- | ---------------------------------------------------------------------------- | | Revocation is surgical | Turning off the vendor’s access does not stop your accounting sync at 2 a.m. | | Usage is attributable | “Who sent 40,000 requests yesterday?” has one answer instead of a shrug. | | Limits are independent | An experiment cannot spend the production key’s daily quota. | | Blast radius is bounded | One leaked key exposes one integration’s scope, not everything you have. | Every key carries a required label. Use it: `accounting-nightly-sync`, `bi-dashboard`, `vendor-poc-2026-09` — a name a colleague can act on a year from now without asking anyone. ## Least privilege [Section titled “Least privilege”](#least-privilege) Two dials decide how much a key can see. Ask for the smallest setting that does the job; you can always be issued a wider key later. **Scope — which shops.** A key is issued either for one shop or for a whole franchise. A franchise key sees every branch under it, now and in the future, including branches that open after the key was issued. If the integration only touches two branches, a franchise key is not the right tool. **Personal data — phone numbers and e-mail.** Member phone numbers and e-mail addresses are **not** returned by default. An ordinary key sees `hasTel` and `hasEmail` — true or false — which is enough to answer “is this member reachable?” without moving personal data out of ScanFood. Keys that return the real values exist, but they are issued deliberately, for a stated purpose, with the data-protection obligations that come with it. Ask “what breaks if this key leaks?” Answer it before you request the key, not after. If the honest answer is “the revenue of 40 branches and 12,000 phone numbers”, ask for a narrower key. Also outside the API’s reach, by design: costs, recipes and ingredient data never leave through it, and no endpoint can write anything back into ScanFood. ## Logging and monitoring [Section titled “Logging and monitoring”](#logging-and-monitoring) * **Redact before you log.** Log the path, the status, the row count and the timing — never the `Authorization` header. A helper like the one above, applied once at the boundary, is worth more than a rule people have to remember. * **Log the `keyId`, not the key.** In `sf_live__`, the `keyId` is a public handle. Recording it with each run means a support question can be answered in minutes. * **Watch for the shape of abuse.** Requests you did not schedule, at hours you do not run, are the signal that a key has escaped. Your own logs will see this before anyone else does. * **Alert on `401`.** A working integration does not produce authentication errors. A burst of them means the key was revoked, rotated behind your back, or is being tried by someone who does not have the whole of it. * **Watch the daily quota.** An unexplained jump in usage is the cheapest leak detector you own — `X-RateLimit-Remaining` on every response is the number to track. ## Rotating a key [Section titled “Rotating a key”](#rotating-a-key) Keys do not expire on their own, so rotation is something you schedule rather than something you are forced into. Because a key is only shown once, rotation is “issue new, then retire old” — there is no way to re-read the current one. The order matters, and done in this order there is no downtime: 1. **Ask for a new key** for the same integration and scope. 2. **Deploy it** to your secret store and restart or reload the caller. 3. **Confirm the new key is the one working** — its usage appears, the old one goes quiet. 4. **Ask us to revoke the old key.** Both keys are valid until you do; there is no window where neither works. Revocation takes up to five minutes A revoked key can keep working for as long as five minutes before every server has caught up. Plan for that when you rotate, and count on it when you respond to a leak — revoking is the first step, not the whole response. After revocation the key answers `401` with `"error": "key_revoked"`, and it stays that way permanently. Revoking a key twice is harmless. ## If a key leaks [Section titled “If a key leaks”](#if-a-key-leaks) A key in a public repository, a screenshot, a pasted log or a former colleague’s laptop is a leaked key. Treat it as leaked even if you are not sure. 1. **Tell us immediately** and ask for that key to be revoked. Quote the `keyId` — the middle segment of the key — not the whole key. 2. **Issue a replacement** and deploy it. If the integration must not stop, do the replacement first (see the rotation order above), then the revocation. 3. **Assume the window was used.** Within the five-minute propagation, the key still worked. Ask us what that key read and when; our usage records are kept per key, with path, status and timestamp. 4. **Close the hole that let it out.** A leaked key that goes back into the same commit history, the same chat channel or the same shared drive will leak again. 5. **Check what the scope exposed.** A single-shop, no-personal-data key that leaked for five minutes is a very different conversation from a franchise key that returns phone numbers. Because the API is read-only, a leaked key cannot void a bill, change a price or delete a member. What it can do is read — which is exactly why the scope you asked for at the beginning is the thing that limits the damage at the end. # Test data > There is no sandbox. Here is how to build and test an integration safely against real data. The ScanFood API reads data that already exists: bills your shops have closed and members your brands have signed up. There is no separate practice copy of it, so you build and test against the same records you will use in production. That sounds riskier than it is. Every endpoint is a `GET`, nothing you send can change a bill, and the only trace a request leaves behind is one line in our usage log and one tick on your daily quota. The rest of this page is about keeping that first week cheap and boring. ## There is no sandbox [Section titled “There is no sandbox”](#there-is-no-sandbox) There is no test environment, no fake shop and no test key that talks to a parallel database. Keys handed out for production start with `sf_live_` and read live records. Do not wait for a sandbox If your plan is “we will develop against the sandbox and switch later”, change it now — there is nothing to switch from. Build against a real shop of yours, with the safeguards below. What protects you instead: | Safeguard | What it means for you | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Every endpoint is read-only | There is no request you can send that edits, voids or deletes anything in ScanFood. | | The key decides what exists | A key sees the shops it was issued for and nothing else. A `shopId` outside that list is a `403`, not a peek. | | Sensitive fields are off by default | Member phone numbers and e-mail addresses are not returned unless the key was explicitly issued with that permission. | | Requests are metered, not billed by mistake | A runaway loop hits the rate limit or the daily quota and stops. It cannot spend anything. | ## Ask for a separate test key [Section titled “Ask for a separate test key”](#ask-for-a-separate-test-key) Ask the ScanFood team for **two keys**: one for the integration you are building, one for people to poke at by hand. Each key carries its own label, its own per-minute limit, its own daily quota and its own usage history. Why it is worth the extra request: * You can revoke the scratch key the day the work is done without touching the running integration. * The usage log separates the two, so “who made 4,000 calls last night” has an answer. * Different quotas mean an experiment cannot eat the production allowance. Ask for the smallest scope that works A key can be issued for a single shop or for a whole franchise. If you are testing against one branch, ask for a single-shop key — you can always be issued a wider one later. ## How to test safely against live data [Section titled “How to test safely against live data”](#how-to-test-safely-against-live-data) Five habits, in the order they matter: 1. **Start with `/v1/shops`.** It takes no parameters, returns only the shops your key can see, and proves the key works before you write anything harder. 2. **Keep date ranges short.** A single day (`from=20260901&to=20260901`) is enough to see the shape of a bill. The endpoint accepts up to 31 days per request; you do not need that while you are still reading JSON by eye. 3. **Keep `limit` small.** The default is 100 and the maximum is 200. Use `limit=5` while you are exploring — the response fits on a screen and the page loop is easier to reason about. 4. **Read `X-RateLimit-Remaining` on every response** while you develop. It is the cheapest early warning you will get. 5. **Pick a quiet shop for the first runs.** Any shop in your key’s scope will do, but a branch that is not mid-service makes a stable target while you compare two runs against each other. ## A first run that costs almost nothing [Section titled “A first run that costs almost nothing”](#a-first-run-that-costs-almost-nothing) This sequence spends four requests and shows you a real bill end to end. * curl ```bash # 0. keep the key out of your shell history read -rs SCANFOOD_API_KEY && export SCANFOOD_API_KEY # 1. which shops can this key see? curl -s -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/shops" # 2. five bills from one day (dates are YYYYMMDD, no dashes) curl -s -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/transactions?shopId=SHOP_ID&from=20260901&to=20260901&limit=5" # 3. one bill in full curl -s -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/transactions/TXN_ID" # 4. how much quota is left today? curl -s -D - -o /dev/null -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/shops" | grep -i x-ratelimit ``` * JavaScript ```js const KEY = process.env.SCANFOOD_API_KEY; const BASE = 'https://api.scanfood.co/ext/v1'; async function get(path) { const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${KEY}` }, }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error}: ${body.message}`); console.log('quota left:', res.headers.get('X-RateLimit-Remaining')); return body; } const { data: shops } = await get('/shops'); const shopId = shops[0].shopId; const { data: bills } = await get( `/transactions?shopId=${shopId}&from=20260901&to=20260901&limit=5`, ); console.log(bills.length, 'bill(s)'); if (bills.length) console.log(await get(`/transactions/${bills[0].id}`)); ``` * Python ```python import os, requests KEY = os.environ["SCANFOOD_API_KEY"] BASE = "https://api.scanfood.co/ext/v1" SESSION = requests.Session() SESSION.headers["Authorization"] = f"Bearer {KEY}" def get(path): res = SESSION.get(BASE + path, timeout=30) body = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {body['error']}: {body['message']}") print("quota left:", res.headers.get("X-RateLimit-Remaining")) return body shops = get("/shops")["data"] shop_id = shops[0]["shopId"] bills = get(f"/transactions?shopId={shop_id}&from=20260901&to=20260901&limit=5")["data"] print(len(bills), "bill(s)") if bills: print(get(f"/transactions/{bills[0]['id']}")) ``` If step 2 comes back with an empty `data` array, that day had no bills at that branch — try a date you know was busy. An empty list is a valid answer, not an error. ## Watch your quota [Section titled “Watch your quota”](#watch-your-quota) Two limits run at the same time, and they fail differently: | Limit | Default | What you see when you cross it | | ---------------------------- | ------- | ----------------------------------------------------------------------------------------------------- | | Requests per minute, per key | 60 | `429` with `"error": "rate_limited"` and a `Retry-After` header telling you how many seconds to wait. | | Requests per day, per key | 10,000 | `429` with `"error": "quota_exceeded"`. The counter resets at 00:00 Thailand time (ICT). | `X-RateLimit-Limit` and `X-RateLimit-Remaining` describe the **daily** quota, not the per-minute burst. They appear on responses that got past authentication and the per-minute check — so a `401`, or a `429` that says `rate_limited`, will not carry them. Both counters count requests **received**, not requests that succeeded. A loop that sends 500 malformed requests spends 500 of your day. This is the single best reason to develop with `limit=5` and a one-day range. Limits are set per key 60 per minute and 10,000 per day are the defaults; both can be raised or lowered for an individual key when it is issued. If your integration needs more, say so when you ask for the key rather than discovering it in production. ## When you are ready for production [Section titled “When you are ready for production”](#when-you-are-ready-for-production) Nothing about the API changes — the same key, the same host, the same responses. What changes is your side, and the [go-live checklist](/before-you-go-live/go-live-checklist/) is the list to walk through before you widen the date range and turn the schedule on. Two things worth doing before that day: * **Backfill first, then switch to incremental.** Page through history with `from`/`to` in 31-day chunks, and only then start polling `updatedSince` for bills. Members have no incremental mode yet and are always a full sync. * **Retire the scratch key.** Ask for it to be revoked once the exploration is finished. Revocation takes effect within five minutes, after which that key answers `401`. # Changelog > Dated list of changes to the ScanFood API. Every change to the API that a caller can observe is recorded here, newest first, in the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. If something you rely on changes, this is the page it will be announced on. ## How we version [Section titled “How we version”](#how-we-version) The version lives in the path. Today every endpoint sits under `/v1/`, and the OpenAPI document reports its own version in `info.version` — fetch `https://api.scanfood.co/ext/v1/openapi.json` if you want to check programmatically which contract you are working against. **Changes that can arrive in `/v1/` at any time:** * a new endpoint; * a new optional query parameter; * a new field in a response object; * a new `error` code for a situation that previously returned a more general one. These are additive: an integration that ignores what it does not recognise keeps working. That is also the requirement they place on you — **parse leniently**. A client that rejects an unknown field will break on a release that breaks nobody else. **Changes that would not arrive in `/v1/` in place:** removing a field, renaming one, narrowing what an endpoint accepts, or changing the meaning of a value already in use. The version sits in the path precisely so that a contract this different arrives as a new path rather than underneath an existing integration. ## Notice period for breaking changes [Section titled “Notice period for breaking changes”](#notice-period-for-breaking-changes) A breaking change is announced **at least 30 days in advance**, on this page and through your ScanFood contact, before the new path goes live. The existing `/v1/` path is not changed underneath you during that period. What you can rely on: * Breaking changes are not made in place under `/v1/`. * Anything observable that does change is recorded here with a date. * Your key keeps working across releases — there is no version pinned to a key, and nothing expires on its own. ## Releases [Section titled “Releases”](#releases) ### 1.3.0 — 2026-09-29 [Section titled “1.3.0 — 2026-09-29”](#130--2026-09-29) **Added** * `categories` on every shop returned by `GET /v1/shops` — the branch’s product categories as a flat list of `{ id, name, level, parentId, hidden }`. The `id` is the same value that appears in a bill line’s `items[].category`, so category ids on bills can now be turned into names and full paths. Every category is listed, including ones the shop has hidden (`hidden: true`), because older bills can still refer to them. An id on a bill that is not in the list belongs to a category the shop has deleted; its name cannot be recovered. See [Shops](/core/shops/#categories). Nothing was removed, renamed or narrowed in this release. The category list is the only catalogue data exposed; products, menus, stock, costs and recipes are still not available through any endpoint. ### 1.2.1 — 2026-09-28 [Section titled “1.2.1 — 2026-09-28”](#121--2026-09-28) **Fixed** * `rankLevel` on members is now always a whole number or `null`, as the reference has always declared. It was previously passed through as stored, so most members came back with a string — usually `""`, sometimes a number in quotes such as `"11"`. A number in quotes now arrives as that number (`"11"` → `11`); an empty string, or a value that is not a valid tier position (such as `"01"`), arrives as `null`. If your code already treated `""` as “no tier”, it keeps working; if it parsed the string, it can now read the number directly. * `rank` on members is now always a string or `null`. A handful of old member records held a number there; those now arrive as `null`. **Documentation** * `items[].category` on bills is documented as what it has always been: an array of category **ids**, from the top-level category down to the most specific one (usually a single id; `[]` when the product had no category). The reference and examples previously described it as a category name. The data itself has not changed. ### 1.2.0 — 2026-09-18 [Section titled “1.2.0 — 2026-09-18”](#120--2026-09-18) **Added** * `GET /v1/members/lookup` — the member token behind a LINE user id you already hold. Give it a `providerId` and a `lineUserId`, and it answers with that person’s `memberId` — the same token that appears on bills and in member listings — so a chat bot can go from “someone just messaged us” to their tier, points and purchase history. The exchange runs one way only: an id goes in, a token comes out, and no response carries a LINE user id back. See [Members](/core/members/#looking-a-member-up-from-a-line-user-id). * A per-key permission for that lookup, `allowLineLookup`, **off by default** and granted the same way `tel` and `email` are. It gates only the new endpoint; nothing about existing responses changes when it is on or off. * `403 lookup_not_enabled` — the answer when a key without that permission calls the lookup. Nothing was removed, renamed or narrowed in this release. An integration that does not use the new endpoint needs no change. ### 1.1.1 — 2026-09-10 [Section titled “1.1.1 — 2026-09-10”](#111--2026-09-10) **Fixed** * Take-away bills now report `channel.code` as `"take_away"`. They were previously reported as `"dine_in"`, with `channel.name` showing the shop’s price-tier name instead of a channel name, so take-away sales were counted as eat-in. Eat-in, pick-up and shop-defined channels are unchanged. If you have already stored bills from this API, take-away sales inside them are on the `dine_in` line and can be moved by re-pulling that date range. ### 1.1.0 — 2026-09-10 [Section titled “1.1.0 — 2026-09-10”](#110--2026-09-10) **Added** * `GET /v1/usage` — where your key stands on its quota, read straight from the counters that enforce it: the per-minute and per-day ceilings the key actually runs under, how many requests it has spent today and how many are left, and the last seven Thailand-time days. It takes no parameters and reports only on the key that calls it. See [Rate limits](/get-started/rate-limits/#check-your-own-usage). **Changed** * Usage records are no longer kept indefinitely. The record of an individual request is kept for **90 days**, and the daily totals that `/v1/usage` reads are kept for **400 days** — long enough to compare a month against the same month last year. Nothing else about a key or its data is affected. ### 1.0.0 — 2026-09-10 [Section titled “1.0.0 — 2026-09-10”](#100--2026-09-10) First public release. **Added** * `GET /v1/shops` — the shops a key can see, with `shopId`, `shopName`, `franchiseId`, `providerId` and the shop’s `timezone`. Not paginated. * `GET /v1/transactions` — bills for one shop, in two modes: a calendar range (`from`/`to`, up to 31 days per request) or an incremental pull (`updatedSince`). Cursor paging with `limit` up to 200, default 100. * `GET /v1/transactions/{id}` — one bill in full: line items, amounts, payments, channel, cashier and void status. * `businessDate` on every bill, alongside `billDate`, so shops that trade past midnight can be reconciled on their own trading day. * `GET /v1/members` — loyalty members for one brand (`providerId`), with points, credit, rank, coupons, labels and visit history. Cursor paging. * `GET /v1/members/{memberToken}` — one member, addressed by the opaque token returned in listings. * `GET /v1/openapi.json` — the full OpenAPI 3.1 document, public and unauthenticated. * API keys in the form `sf_live__`, sent as `Authorization: Bearer …`, each scoped to a single shop or to a whole franchise, each with its own label, per-minute limit and daily quota. * Rate limiting and quota reporting: `X-RateLimit-Limit` and `X-RateLimit-Remaining` for the daily quota, `Retry-After` on a per-minute `429`, and a daily counter that resets at 00:00 Thailand time. * Key revocation, effective within five minutes, after which the key answers `401 key_revoked`. * `api.scanfood.co` as the single host for the API and for this documentation. * This documentation site, including [machine-readable copies](/ai/llms-txt/) of every guide. **Known limitations at 1.0.0** * `updatedSince` is not available on `/v1/members`; it returns `400 not_supported`. Members are synchronised in full, page by page. * Bills closed before change tracking existed carry no `updatedAt` and therefore never appear in `updatedSince` results — backfill with `from`/`to` first. * No webhooks and no push: everything is pulled on your schedule. * No sandbox and no test dataset — see [Test data](/before-you-go-live/test-data/). * Cross-origin requests are refused; the API is server-to-server only. * Products, menus, stock, costs and recipes are not exposed by any endpoint. (Since 1.3.0, the shop’s category list is available on `/v1/shops`; the rest of this still holds.) # Members > Membership records and the coupons attached to them. A **member** is one person enrolled in a shop’s membership programme: their tier, their usable points and store credit, their coupons, and a small amount of behavioural summary (how many visits, when they last came). This is the resource a CRM, a loyalty engine or a customer-analytics warehouse reads. Three endpoints: * [`GET /v1/members`](/api/operations/listmembers/) — a page of members for one membership base * [`GET /v1/members/{memberToken}`](/api/operations/getmember/) — one member by token * [`GET /v1/members/lookup`](/api/operations/lookupmember/) — the member token behind a LINE user id you already hold All three are read-only. ## What a member is [Section titled “What a member is”](#what-a-member-is) Membership in ScanFood does not belong to a shop — it belongs to a **membership base**, identified by `providerId`. Several branches routinely share one base, so a customer who joined at one branch is recognised at every branch that shares it. That is why this endpoint is scoped by `providerId` and **not** by `shopId`. | Key scope | Membership bases you can read | | ----------- | ----------------------------------------------- | | `shop` | the one `providerId` belonging to that shop | | `franchise` | every distinct `providerId` under the franchise | `providerId` is **required** on the list endpoint, and it must be one your key covers — otherwise you get `403 provider_not_in_scope`. Get the list of values you are allowed to use from [`GET /v1/shops`](/core/shops/). Caution Franchise structure and membership structure are **separate axes**. Two branches can share a membership base without sharing a franchise, and two branches of the same franchise can each run their own base. Always derive `providerId` from the shop list — never assume one base per shop or one base per franchise. And deduplicate: syncing the same base once per branch multiplies your quota use for no extra data. What is stored about a member is far more than what is returned: the underlying record holds their messaging account, phone, email, birthday, gender, photos, free-text notes, full visit history with per-visit amounts, and every points and credit movement. **The API returns a fixed, hand-picked subset** — see [Privacy](#privacy). ## Member tokens [Section titled “Member tokens”](#member-tokens) Members are identified by an **opaque token** that always begins with `m1_`: ```plaintext m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM ``` The token is the *only* identifier this API exposes for a person. It replaces the internal record id, which is built from the customer’s messaging account and would leak their real identity if it were handed out. What you can rely on: | Property | Detail | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------- | | **Stable** | The same person always produces the same token. Safe as a primary key, a join key or a cursor. | | **Global** | The token does not depend on which key, shop or franchise you look from. Two keys reading the same person see the same token. | | **Not reversible** | You cannot recover the customer’s identity from the token. | | **Versioned** | The `m1_` prefix is a version marker. Should the encoding ever be rotated, new tokens would begin `m2_`. | The `memberId` on a bill is exactly this token, so bill-to-member joins need no translation step: ```js // A bill from /v1/transactions if (bill.memberId) { const member = await getMember(bill.memberId); // /v1/members/{memberToken} } ``` Caution Never pass an internal record id where a token is expected. The list endpoint rejects it with `400 bad_cursor` (`cursor is not a valid member token`), and the single-member endpoint answers `404 not_found` — the same answer it gives for a member who does not exist and for a member outside your scope. That sameness is deliberate: the API will not confirm whether an identifier exists outside what your key covers. ## Looking a member up from a LINE user id [Section titled “Looking a member up from a LINE user id”](#looking-a-member-up-from-a-line-user-id) If you already hold a customer’s **LINE user id** — because they are talking to your bot, or opened your LIFF app — you can exchange it for that person’s member token without asking them for anything: * [`GET /v1/members/lookup`](/api/operations/lookupmember/) — one member token, from a LINE user id ```plaintext GET /ext/v1/members/lookup?providerId=pv3Yh9DkQ2sLmT6RwX1B&lineUserId=U4af4980629… ``` The answer is the token and nothing else: ```json { "ok": true, "data": { "memberId": "m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM" }, "serverTime": "2026-09-18T04:10:00.000Z" } ``` It is the same token as everywhere else — so [`GET /v1/members/{memberToken}`](/api/operations/getmember/) gives you tier, points and coupons, and every bill in [`GET /v1/transactions`](/core/transactions/) carrying that `memberId` is a purchase by the same person. This is the “a customer just messaged us — are they a member, and who are they to us?” case. Note the direction of travel: you supply a LINE user id **you already have**, and get a token back. The API still does not hand a LINE user id out — `hasLine` remains the only thing a member response says about that channel. Caution A LINE user id is **per LINE Provider**, not global. The id your bot sees is the same string ScanFood stored only if your bot or LIFF app sits under the **same LINE Official Account (or Provider)** the brand uses to sign members up. Under a different Provider the same human carries a completely different id, and this endpoint will answer `404 not_found` every time — not because the person is not a member, but because the id you hold belongs to another namespace. Confirm which Provider the brand enrols under before drawing conclusions from the answers. ### Switched on per key [Section titled “Switched on per key”](#switched-on-per-key) Lookup is **off by default** and enabled per key, the same way `tel` and `email` are (see [Privacy](#privacy)). A key without it gets `403 lookup_not_enabled` on every call. Turning it on is a commercial and legal decision about lawful basis and consent under Thailand’s personal data protection law, not a technical toggle — ask your ScanFood contact, and expect to say what the lookup will be used for. ### Parameters [Section titled “Parameters”](#parameters) | Parameter | Required | Notes | | ------------ | -------- | -------------------------------------------------------------------------------------------- | | `providerId` | **yes** | The membership base to search. Must be covered by your key, else `403 provider_not_in_scope` | | `lineUserId` | **yes** | The customer’s LINE user id: `U` followed by 32 hexadecimal characters | ### Errors [Section titled “Errors”](#errors) | Status | `error` | When it happens | | ------ | ----------------------- | ---------------------------------------------------------------------- | | `400` | `bad_query` | `providerId` is empty, or `lineUserId` is not in the `U` + 32 hex form | | `403` | `provider_not_in_scope` | The membership base is outside what your key covers | | `403` | `lookup_not_enabled` | Your key does not have lookup switched on | | `404` | `not_found` | No member matched | Caution `404` is **one answer for every reason**: not a member of this brand, a member of a different brand, or nobody at all. The API does not distinguish between them, for the same reason it does not on `GET /v1/members/{memberToken}` — it will not confirm anything about a person outside what your key covers. * curl ```bash curl -s -G https://api.scanfood.co/ext/v1/members/lookup \ -H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \ --data-urlencode "providerId=pv3Yh9DkQ2sLmT6RwX1B" \ --data-urlencode "lineUserId=U4af4980629304a1b8c2d3e4f5a6b7c8d" ``` * JavaScript ```js const BASE = 'https://api.scanfood.co/ext/v1'; const KEY = process.env.SCANFOOD_API_KEY; /** LINE user id -> member token, or null when there is no match. */ async function lookupMember(providerId, lineUserId) { const url = new URL(`${BASE}/members/lookup`); url.searchParams.set('providerId', providerId); url.searchParams.set('lineUserId', lineUserId); const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }); if (res.status === 404) return null; // not a member of this brand const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error}: ${body.message}`); return body.data.memberId; } // On an inbound chat message — cache the result, do not call per message. const token = await lookupMember('pv3Yh9DkQ2sLmT6RwX1B', event.source.userId); if (token) { const member = await getMember(token); // /v1/members/{memberToken} console.log(member.rankName, member.points); } ``` * Python ```python import os import requests BASE = "https://api.scanfood.co/ext/v1" HEADERS = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"} def lookup_member(provider_id, line_user_id): """LINE user id -> member token, or None when there is no match.""" res = requests.get( f"{BASE}/members/lookup", headers=HEADERS, params={"providerId": provider_id, "lineUserId": line_user_id}, timeout=60, ) if res.status_code == 404: return None # not a member of this brand body = res.json() if res.status_code != 200: raise RuntimeError(f"{res.status_code} {body['error']}: {body['message']}") return body["data"]["memberId"] token = lookup_member("pv3Yh9DkQ2sLmT6RwX1B", event.source.user_id) if token: member = get_member(token) # /v1/members/{memberToken} print(member["rankName"], member["points"]) ``` Tip The mapping from LINE user id to member token does not change once it exists, so **cache it on your side**, keyed on the LINE user id, and look a customer up once rather than once per message. A bot that calls this on every inbound message spends its daily quota re-answering a question it already answered. If someone signed up more than once and holds several records in the same membership base, the lookup answers with the **most recently created** one. ## Fields [Section titled “Fields”](#fields) Response envelope for the list endpoint: ```json { "ok": true, "data": [ { "…": "one object per member" } ], "nextCursor": "m1_…", "serverTime": "2026-09-10T01:45:00.000Z" } ``` The single-member endpoint returns the same object under `data` with no `nextCursor`. | Field | Type | Nullable | Meaning | Example | | --------------- | --------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | | `memberId` | string | **yes** | The opaque member token. Same value as `memberId` on a bill. | `"m1_ZmQ3YjJkNGE1…"` | | `rank` | string | **yes** | The tier code stored on the member record. `null` when none is recorded. | `"GOLD"` | | `rankLevel` | number | **yes** | The member’s position in the base’s tier list, as a whole number counted from `0`. Meaningful only where the base has automatic tier progression turned on. `null` when the member has no valid position recorded. | `2` | | `rankName` | string | **yes** | **The tier name the member is on right now**, resolved against the base’s current tier list. `null` if the member has no tier. Prefer this over `rank` for display. | `"โกลด์"` | | `points` | number | no | **Usable points, after expiry has been applied** — not the lifetime total ever earned. | `14097` | | `credit` | number | no | **Usable store credit, after expiry.** `0` when the base does not run store credit at all. | `200` | | `creditEnabled` | boolean | no | Whether this membership base runs store credit. Read this before showing `credit` to anyone. | `true` | | `coupons` | array | no (may be `[]`) | Coupons in the member’s wallet, see [Coupons](#coupons). | — | | `labels` | string\[] | no (may be `[]`) | Tags the shop attached to the member. | `["vip", "แพ้ถั่ว"]` | | `persona` | string\[] | no (may be `[]`) | Persona tags the shop attached to the member. | `["สายหวาน"]` | | `createdDate` | string | **yes** | When the member signed up. ISO-8601 with a `+07:00` (Thailand) offset — membership bases carry no timezone of their own, so member times are always Thailand time. | `"2026-03-04T16:15:00.000+07:00"` | | `visitCount` | integer | no | Number of recorded visits. **The visit list itself is not returned.** | `57` | | `lastVisit` | string | **yes** | Date of the most recent visit, `YYYY-MM-DD`. `null` if the member has never visited. | `"2026-09-07"` | | `hasTel` | boolean | no | Whether a phone number exists on the record — **not the number itself**. | `true` | | `hasEmail` | boolean | no | Whether an email address exists on the record. | `false` | | `hasLine` | boolean | no | Whether a messaging account is linked. | `true` | Two further fields appear **only for keys explicitly granted personal-data access** (see [Privacy](#privacy)): | Field | Type | Nullable | | ------- | ------ | -------- | | `tel` | string | **yes** | | `email` | string | **yes** | Tip `points` and `credit` are the **spendable** balances, computed the same way the customer’s own membership app computes them — expiry rules already applied. Do not try to reconstruct them from movement history; that history is not exposed, and re-deriving expiry yourself is how a loyalty programme ends up disagreeing with the receipt in the customer’s hand. Caution `credit` is `0` — not `null` — for a base that has store credit switched off. Always check `creditEnabled` before you present a credit balance, or every member of such a base will look like someone who spent their credit down to zero. Fields **deliberately absent**: the member’s name and nickname, phone and email (unless granted), messaging account id, birthday, gender, photos, address, free-text notes, saved cards, the raw visit history, and the raw points and credit movement lists. There is also **no loyalty segment** (Loyal, At-Risk, New and so on) — those are computed in batch elsewhere and are not attached to the member record. Derive them yourself from `visitCount`, `lastVisit` and transaction history. ## Coupons [Section titled “Coupons”](#coupons) `coupons[]` is the member’s coupon wallet. Each entry: | Field | Type | Nullable | Meaning | | ------------ | ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `code` | string | **yes** | The short code the member shows at the counter. Unique per issued coupon. | | `status` | `"available"` \| `"used"` | **yes** | `"available"` — still redeemable. `"used"` — already redeemed. | | `templateId` | string | **yes** | The coupon definition this one was issued from. Many coupons share one `templateId`. | | `name` | string | **yes** | The coupon name as the member sees it. | | `expireAt` | string | **yes** | Expiry, ISO-8601 with a `+07:00` (Thailand) offset, like every other member time. `null` when the coupon never expires. | Two things worth planning for: * A coupon with `status: "used"` stays in the wallet. Filter on `status` before you show “coupons available”. * `status` describes **redemption**, not expiry. Treat `expireAt` as a separate condition and compare it against `serverTime` rather than the clock on your own machine. Coupons are always read within the membership base you asked for, so a coupon belonging to another brand can never appear in the wallet. ## Listing every member [Section titled “Listing every member”](#listing-every-member) There is **no incremental mode for members.** `updatedSince` is rejected with `400 not_supported`. Caution This is a deliberate refusal, not a missing feature. Roughly three quarters of existing member records predate change tracking and have never been touched since, so an incremental sync would silently skip them — you would get a partial customer base with nothing to tell you it was partial. Rather than hand you a quiet gap, the API refuses the mode and asks you to page through everything. So: page through the whole base with `cursor`. | Parameter | Required | Default | Notes | | -------------- | -------- | ------- | --------------------------------------------------------------- | | `providerId` | **yes** | — | Must be covered by your key, else `403 provider_not_in_scope` | | `limit` | no | `100` | Above 200 is reduced to 200; an invalid value falls back to 100 | | `cursor` | no | — | The `nextCursor` from the previous page — a `m1_…` token | | `updatedSince` | — | — | **Not supported** — `400 not_supported` | Members come back ordered by **sign-up date, oldest first**, which makes a full pass stable: rows already returned do not jump ahead of you while you page. `nextCursor` is `null` on the last page. * curl ```bash # first page curl -s -G https://api.scanfood.co/ext/v1/members \ -H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \ --data-urlencode "providerId=pv3Yh9DkQ2sLmT6RwX1B" \ --data-urlencode "limit=200" # next page — pass the nextCursor from the response above curl -s -G https://api.scanfood.co/ext/v1/members \ -H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \ --data-urlencode "providerId=pv3Yh9DkQ2sLmT6RwX1B" \ --data-urlencode "limit=200" \ --data-urlencode "cursor=m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM" ``` * JavaScript ```js const BASE = 'https://api.scanfood.co/ext/v1'; const KEY = process.env.SCANFOOD_API_KEY; async function getPage(path, params) { const url = new URL(`${BASE}${path}`); for (const [k, v] of Object.entries(params)) { if (v != null) url.searchParams.set(k, v); } const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error}: ${body.message}`); return body; } /** Full pass over one membership base. */ async function syncMembers(providerId, onMember) { let cursor = null; let seen = 0; do { const page = await getPage('/members', { providerId, limit: 200, cursor }); for (const member of page.data) { onMember(member); // upsert on member.memberId seen += 1; } cursor = page.nextCursor; // Persist `cursor` here if you want to resume an interrupted run. } while (cursor); return seen; } const total = await syncMembers('pv3Yh9DkQ2sLmT6RwX1B', upsertMember); console.log('synced', total, 'members'); // One member, straight from a bill async function getMember(token) { const body = await getPage(`/members/${encodeURIComponent(token)}`, {}); return body.data; } ``` * Python ```python import os import requests from urllib.parse import quote BASE = "https://api.scanfood.co/ext/v1" HEADERS = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"} def get_page(path, params=None): query = {k: v for k, v in (params or {}).items() if v is not None} res = requests.get(f"{BASE}{path}", headers=HEADERS, params=query, timeout=60) body = res.json() if res.status_code != 200: raise RuntimeError(f"{res.status_code} {body['error']}: {body['message']}") return body def sync_members(provider_id, on_member): """Full pass over one membership base.""" cursor, seen = None, 0 while True: page = get_page("/members", { "providerId": provider_id, "limit": 200, "cursor": cursor, }) for member in page["data"]: on_member(member) # upsert on member["memberId"] seen += 1 cursor = page["nextCursor"] # Persist `cursor` here if you want to resume an interrupted run. if not cursor: return seen total = sync_members("pv3Yh9DkQ2sLmT6RwX1B", upsert_member) print("synced", total, "members") def get_member(token): """One member, straight from a bill.""" return get_page(f"/members/{quote(token, safe='')}")["data"] ``` Tip A full pass is cheap in requests — 200 members per call — but it is still a full pass. Schedule it once a day rather than every few minutes, and use [`GET /v1/members/{memberToken}`](/api/operations/getmember/) for the “a bill just came in, who is this?” case in between. That keeps you inside your daily quota without going stale on the members who actually transacted. If a `cursor` points at a member who has since been removed, you get `400 bad_cursor`. Restart that page without a cursor rather than treating it as a fatal error. ## Example response [Section titled “Example response”](#example-response) ```json { "ok": true, "data": [ { "memberId": "m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM", "rank": "GOLD", "rankLevel": 2, "rankName": "โกลด์", "points": 14097, "credit": 200, "creditEnabled": true, "coupons": [ { "code": "ABC123", "status": "available", "templateId": "cp7Vb2NqZ5tMx8LwR3Kd", "name": "ส่วนลด 50 บาท", "expireAt": "2026-12-31T23:59:59.000+07:00" }, { "code": "XYZ789", "status": "used", "templateId": "cpQ4mL8vT2zNb6RwK9Ys", "name": "ฟรีชาเย็น", "expireAt": null } ], "labels": ["vip", "แพ้ถั่ว"], "persona": ["สายหวาน"], "createdDate": "2026-03-04T16:15:00.000+07:00", "visitCount": 57, "lastVisit": "2026-09-07", "hasTel": true, "hasEmail": false, "hasLine": true } ], "nextCursor": null, "serverTime": "2026-09-10T01:45:00.000Z" } ``` ## Privacy [Section titled “Privacy”](#privacy) Membership data is personal data, and this API is built so that a key cannot accidentally acquire more of it than it was given. **By default, no contact details are returned at all.** You get `hasTel`, `hasEmail` and `hasLine` — enough to know whether a channel exists and to segment on it — but not the values. Phone and email can be switched on **per key**, and only those two fields; there is no setting that opens anything else. A third, separate flag opens [member lookup](#looking-a-member-up-from-a-line-user-id) — and note what that flag does and does not do: it lets a key **send in** a LINE user id it already holds and receive a member token. It does not add the messaging account id to any response. No key level ever reads one out of ScanFood. Turning them on is a commercial and legal decision about lawful basis and consent under Thailand’s personal data protection law, not a technical toggle — ask your ScanFood contact, and expect to say what the data will be used for. Everything else about the person stays inside ScanFood permanently: | Category | Not available at any key level | | ------------------ | --------------------------------------------------------------------------------------------------------------- | | Identity | name, nickname, messaging account id, birthday, gender, photos, address | | Free text | staff notes on the member | | Raw history | full visit history (with bill ids and per-visit amounts), points movements, credit movements, promotion history | | Stored instruments | saved cards | | Internal | edit metadata, spending counters, tier-change bookkeeping | Caution The member’s **name is not returned**, and neither is any name on a bill. If your system needs to display a human name next to a loyalty record, you must collect and store that yourself, under your own consent, and key it on `memberId`. Plan for this before you design the screen — no key level unlocks it. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * [Transactions](/core/transactions/) — `memberId` on a bill points straight at a member. * [Shops](/core/shops/) — where `providerId` comes from. * [Pagination and sync](/get-started/pagination-and-sync/) — cursor rules in detail. * [Errors](/get-started/errors/) — every code these endpoints can return. * [Security best practices](/before-you-go-live/security-best-practices/). # Shops > The shops a key can reach, and what each shop record tells you. A **shop** is one physical branch that records sales in ScanFood. Every bill and every membership record in this API hangs off a shop, so the shop list is where an integration starts: it tells you which branches your key can read, and it hands you the two identifiers — `shopId` and `providerId` — that the other endpoints require. It also carries each branch’s category list, which is how you turn the category ids on a bill into names. There is exactly one endpoint: [`GET /v1/shops`](/api/operations/listshops/). ## What a shop is [Section titled “What a shop is”](#what-a-shop-is) A shop record here is deliberately thin. It is a **directory entry**, not a profile: enough to identify a branch, place it inside a franchise, point at its membership database, tell you which wall clock its calendar dates are on, and name the categories its bills refer to. | You want to… | Use | | ----------------------------------------------------- | ---------------------------------------------------------------------- | | Know which branches this key can read | `shopId` of every row | | Pull sales for a branch | `shopId` → [`GET /v1/transactions`](/api/operations/listtransactions/) | | Pull the membership base a branch sells to | `providerId` → [`GET /v1/members`](/api/operations/listmembers/) | | Group branches by business | `franchiseId` | | Interpret `from` / `to` and `billDate` | `timezone` | | Turn `items[].category` on a bill into category names | `categories` | Everything else a branch holds — staff accounts, messaging credentials, payment gateway settings, addresses, contact numbers — never leaves ScanFood. The server reads only these fields out of storage — the branch name (either of the two spellings a shop record may use), `franchiseId`, `providerId`, `timezone` and the category list — so the rest cannot leak even by accident. Even inside the category list, only the five fields described under [Categories](#categories) are passed on. `shopId` is not one of them: it is the record’s own identifier, not a field stored inside it. ## Shop scope [Section titled “Shop scope”](#shop-scope) **Your key decides which shops you see. A request cannot widen it.** There is no `shopId` parameter on this endpoint, and no way to ask for a branch that was not granted to the key. | Key scope | Bound to | Shops returned | Membership bases reachable | | ----------- | ------------- | ------------------------------ | ------------------------------------ | | `shop` | one branch | that one shop | the `providerId` of that shop | | `franchise` | one franchise | every branch of that franchise | every distinct `providerId` under it | Tip Call `/v1/shops` once at the start of an integration and again whenever a sync run finds an unfamiliar `shopId` on a bill. A franchise key picks up new branches automatically — nothing has to be reissued when a branch opens. If a branch was granted to the key but its record has since been removed, the row is simply skipped. You get a shorter list, not an error. ## Fields [Section titled “Fields”](#fields) Response envelope: ```json { "ok": true, "data": [ { "…": "one object per shop" } ], "serverTime": "2026-09-09T13:45:12.310Z" } ``` `serverTime` is the server’s clock in UTC (`Z` suffix) and appears on every successful response. There is **no `nextCursor`** on this endpoint — the shop list is never paginated, you always receive every branch in scope in one response. Each object in `data`: | Field | Type | Nullable | Meaning | Example | | ------------- | ------ | ---------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `shopId` | string | no | The branch identifier. This is the value you pass as `shopId` to the transactions endpoint. | `"sh7Kq2mVbN4tRxZ0Lp8W"` | | `shopName` | string | **yes** | Display name of the branch, as the owner typed it. | `"ร้านตัวอย่าง สาขาสีลม"` | | `franchiseId` | string | **yes** | The franchise this branch belongs to. `null` for an independent shop. | `"fr5Ns8CtJ4vHqZ2WbY7K"` | | `providerId` | string | **yes** | The membership base this branch sells to. `null` if the branch runs no membership programme. | `"pv3Yh9DkQ2sLmT6RwX1B"` | | `timezone` | string | **yes** | IANA time zone of the branch. Calendar dates (`from`, `to`, `billDate`) are days on *this* clock. | `"Asia/Bangkok"` | | `categories` | array | no (may be `[]`) | The branch’s product categories as a flat list — see [Categories](#categories). `[]` when the branch has none. | `[{ "id": "9369…", "name": "อาหาร", "level": 1, "parentId": null, "hidden": false }]` | Caution `providerId` is **not** one-per-shop. Several branches commonly share a single membership base, and franchise structure and membership structure are separate axes — two branches can share members without sharing a franchise, and vice versa. Deduplicate `providerId` before you loop over it, or you will sync the same members several times and burn quota for nothing. Fields that are **deliberately absent**, so you do not go looking for them: addresses and phone numbers, tax registration details, opening hours, staff lists, device inventories, payment or messaging credentials, and anything about cost or recipes. None of these are available through this API at any key level. ## Categories [Section titled “Categories”](#categories) Each line on a bill carries `items[].category`: the category **ids** the product sat in at the time of sale, top-level first (see [Transactions](/core/transactions/)). `categories` is where those ids get their names. It is a flat list, ordered by `level` (top level first) and then in the order the shop arranged its categories. | Field | Type | Nullable | Meaning | Example | | ---------- | ------- | -------- | ------------------------------------------------------------------------------------- | ---------------------------------------- | | `id` | string | no | Category id — the same value that appears in `items[].category`. | `"eff254c9-3d71-4d6f-9e43-1806ece6a427"` | | `name` | string | **yes** | Category name as the shop set it, in the shop’s own language. `null` when left empty. | `"ก๋วยเตี๋ยว"` | | `level` | integer | **yes** | Depth: `1` is a top-level category, `2` sits directly under one, and so on. | `2` | | `parentId` | string | **yes** | Id of the category directly above. `null` for a top-level category. | `"9369601f-fef5-4a61-bfb8-26f25eab3dad"` | | `hidden` | boolean | no | `true` when the shop has hidden the category from every sales channel. | `false` | Things to know when you use it: * **Every category is listed, hidden ones included.** A shop can hide a category today that older bills still refer to, so hidden categories stay in the list with `hidden: true`. Filter on `hidden` yourself if you only want what is on sale now. * **A deleted category is gone.** An id on a bill that is not in `categories` belongs to a category the shop has since deleted; its name cannot be recovered. The same goes for a `parentId` that points at an id not in the list. Report these as “uncategorised” (or keep the raw id) rather than failing. * **Names are current, ids are historical.** `categories` is today’s list, so a renamed category shows its new name even on old bills. That is usually what you want for reporting; store the name alongside the bill yourself if you need the name as it was on the day. * **Promotional groupings are not categories.** “Best sellers”, “promotions” and “recommended” shelves in the shop’s menu never appear in `items[].category`, and are not listed here. * Only the name the shop typed is returned; translated names and category images are not. To turn a bill line into names, look each id up in the branch’s list. The last id in `items[].category` is the most specific category; the whole array is the path from the top. ```js // shops = data from GET /v1/shops const categoryName = new Map(); for (const shop of shops) { for (const c of shop.categories) categoryName.set(`${shop.shopId}:${c.id}`, c.name); } function categoryPath(bill, item) { // e.g. ["อาหาร", "ก๋วยเตี๋ยว"] — null for a category that has been deleted return (item.category || []).map((id) => categoryName.get(`${bill.shopId}:${id}`) ?? null); } ``` Category ids are unique inside a shop, but look them up per `shopId` as above — franchise branches keep their own category lists. ## Typical use [Section titled “Typical use”](#typical-use) Fetch the directory, then fan out over it. * curl ```bash curl -s https://api.scanfood.co/ext/v1/shops \ -H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" ``` * JavaScript ```js const BASE = 'https://api.scanfood.co/ext/v1'; const KEY = process.env.SCANFOOD_API_KEY; // sf_live_… async function listShops() { const res = await fetch(`${BASE}/shops`, { headers: { Authorization: `Bearer ${KEY}` }, }); if (!res.ok) { const err = await res.json(); throw new Error(`${res.status} ${err.error}: ${err.message}`); } const body = await res.json(); return body.data; } const shops = await listShops(); // The two lists an integration usually needs. const shopIds = shops.map((s) => s.shopId); const providerIds = [...new Set(shops.map((s) => s.providerId).filter(Boolean))]; console.log(shopIds.length, 'shops,', providerIds.length, 'membership bases'); ``` * Python ```python import os import requests BASE = "https://api.scanfood.co/ext/v1" KEY = os.environ["SCANFOOD_API_KEY"] # sf_live_… HEADERS = {"Authorization": f"Bearer {KEY}"} def list_shops(): res = requests.get(f"{BASE}/shops", headers=HEADERS, timeout=30) if res.status_code != 200: err = res.json() raise RuntimeError(f"{res.status_code} {err['error']}: {err['message']}") return res.json()["data"] shops = list_shops() shop_ids = [s["shopId"] for s in shops] provider_ids = sorted({s["providerId"] for s in shops if s["providerId"]}) print(len(shop_ids), "shops,", len(provider_ids), "membership bases") ``` A typical response for a two-branch franchise key: ```json { "ok": true, "data": [ { "shopId": "sh7Kq2mVbN4tRxZ0Lp8W", "shopName": "ร้านตัวอย่าง สาขาสีลม", "franchiseId": "fr5Ns8CtJ4vHqZ2WbY7K", "providerId": "pv3Yh9DkQ2sLmT6RwX1B", "timezone": "Asia/Bangkok", "categories": [ { "id": "9369601f-fef5-4a61-bfb8-26f25eab3dad", "name": "อาหาร", "level": 1, "parentId": null, "hidden": false }, { "id": "3c1d7e52-8a0b-4f6e-9d21-5b7a0c4e9f13", "name": "เครื่องดื่ม", "level": 1, "parentId": null, "hidden": true }, { "id": "eff254c9-3d71-4d6f-9e43-1806ece6a427", "name": "ก๋วยเตี๋ยว", "level": 2, "parentId": "9369601f-fef5-4a61-bfb8-26f25eab3dad", "hidden": false } ] }, { "shopId": "shB3xW9pL2knT6ZqR4Vd", "shopName": "ร้านตัวอย่าง สาขาอโศก", "franchiseId": "fr5Ns8CtJ4vHqZ2WbY7K", "providerId": "pv3Yh9DkQ2sLmT6RwX1B", "timezone": "Asia/Bangkok", "categories": [] } ], "serverTime": "2026-09-09T13:45:12.310Z" } ``` Both branches point at the same `providerId` — sync that membership base **once**, not twice. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * [Transactions](/core/transactions/) — pull the bills for each `shopId`. * [Members](/core/members/) — pull the membership base for each `providerId`. * [Time zones and business date](/get-started/timezone-and-business-date/) — why `timezone` decides what “yesterday” means. * [Authentication](/get-started/authentication/) — how key scope is granted. # Transactions > Bills recorded by the POS: items, amounts, payments and status. A **transaction** is one closed bill: what was ordered, what was discounted, what tax was applied, what the customer paid with, and whether the bill was later voided. This is the resource an accounting system, a BI warehouse or a daily reconciliation job spends almost all of its time on. Two endpoints: * [`GET /v1/transactions`](/api/operations/listtransactions/) — a page of bills for one shop * [`GET /v1/transactions/{id}`](/api/operations/gettransaction/) — one bill by id Both are read-only. Nothing you do here changes anything inside ScanFood. ## What a transaction is [Section titled “What a transaction is”](#what-a-transaction-is) A bill is created when a cashier closes a sale. One bill can absorb several orders (a table that ordered three times pays once), which is why `orderIds` is an array. **What the API returns is a fixed, hand-picked projection of the bill** — a list of fields written out one by one on the server. A real bill in storage carries far more than this, including cost prices and recipes. Nothing outside the list below can appear in the response, now or after a future change. Note **Every field below is present on every bill.** Where there is no value you get `null` (or `[]` for a list), never a missing key — so you can index straight into the object without existence checks. The [OpenAPI document](/api/operations/listtransactions/) declares every one of them as required for the same reason. Who uses this: | Use case | What you pull | | ----------------------------------------------- | ----------------------------------------------------------- | | Daily reconciliation with the shop’s own report | one calendar range per shop, grouped by `businessDate` | | Continuous sync into a warehouse | `updatedSince`, every 15 minutes | | Menu / item analytics | `items[]` across a date range | | Loyalty attribution | `memberId` on the bill, joined to [Members](/core/members/) | | Channel P\&L (dine-in vs delivery) | `channel.code` + `amounts` | ## Bill fields [Section titled “Bill fields”](#bill-fields) Response envelope for the list endpoint: ```json { "ok": true, "data": [ { "…": "one object per bill" } ], "nextCursor": null, "serverTime": "2026-09-01T06:30:00.000Z" } ``` The single-bill endpoint returns the same object under `data` with no `nextCursor`. | Field | Type | Nullable | Meaning | Example | | --------------- | ------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | `id` | string | no | Bill identifier. Stable, safe to use as a primary key, and also the value used as a `cursor`. | `"tx9Bd2LmQ7sVfR4KpN1C"` | | `orderIds` | string\[] | no (may be `[]`) | The orders merged into this bill. | `["or4Tq8ZmB2vNs6WkY9Hd"]` | | `receiptNumber` | string | **yes** | The receipt number printed for the customer. | `"RC-001"` | | `taxNumber` | string | **yes** | Full tax invoice number. Only present on bills where a tax invoice was issued. | `"TIV-2026-000144"` | | `timestamp` | string | **yes** | When the bill was closed. ISO-8601 **with the shop’s UTC offset**. | `"2026-09-01T13:30:00.000+07:00"` | | `businessDate` | string | **yes** | The trading day this bill counts towards, `YYYY-MM-DD`. See below. | `"2026-09-01"` | | `billDate` | string | **yes** | The calendar day on the shop’s clock, `YYYY-MM-DD`. **This is the field `from`/`to` filter on.** | `"2026-09-01"` | | `updatedAt` | string | **yes** | Last time the bill changed. ISO-8601 with shop offset. `null` on bills closed before change tracking existed. | `"2026-09-01T13:30:00.000+07:00"` | | `franchiseId` | string | **yes** | Franchise the shop belongs to. | `"fr5Ns8CtJ4vHqZ2WbY7K"` | | `shopId` | string | no | Shop that issued the bill. | `"sh7Kq2mVbN4tRxZ0Lp8W"` | | `deviceId` | string | **yes** | Device that closed the bill. | `"dv6Mk1PqR8tZbN3WxL5J"` | | `stationId` | string | **yes** | POS station that closed the bill. | `"st2Wc7YnV5qLpK8ZmR4T"` | | `channel` | object | no | Sales channel — `{ "code", "name" }`, see below. | `{ "code": "dine_in", "name": "โต๊ะ 3" }` | | `serviceType` | string | **yes** | `"alacarte"` for a normal sale, otherwise the **id** of the buffet package that was sold, not its display name. **Not a closed set** — each shop defines its own packages. | `"alacarte"` | | `tableName` | string | **yes** | Table name for dine-in bills. | `"โต๊ะ 3"` | | `memberId` | string | **yes** | Opaque member token (`m1_…`) when the bill was attached to a member; `null` otherwise. | `"m1_ZmQ3YjJkNGE1…"` | | `items` | array | no (may be `[]`) | Lines on the bill, see [Items](#items). | — | | `amounts` | object | no | Bill totals, see [Amounts and tax](#amounts-and-tax). | — | | `payments` | array | no (may be `[]`) | How the customer paid, see [Payments](#payments). | — | | `status` | `"completed"` \| `"void"` | no | `"void"` means the whole bill was cancelled after it was closed. | `"completed"` | | `voidReason` | string | **yes** | Reason recorded at cancellation; `null` when not voided. | `"กดผิดเมนู"` | | `cashier` | string | **yes** | **Display name** of the staff member who closed the bill. Not a staff code. | `"สมชาย ใจดี"` | ### `channel` [Section titled “channel”](#channel) `channel.code` tells you how the sale reached the shop: | `code` | Meaning | | --------------------------- | ------------------------------------------------------------------------ | | `"dine_in"` | Eaten in, ordered at a table | | `"take_away"` | Rung up at the counter to be taken away | | `"pickup"` | Customer ordered themselves (QR / self-order link) and collected | | *a shop-defined channel id* | A channel the shop created — delivery platform, phone order, marketplace | | `null` | The bill carries no table or channel reference | `channel.name` is the human label: the shop’s own channel name when it matches, otherwise the table name, otherwise `null`. Caution `channel.code` is an **open string, not a fixed enum**. A shop can add a new sales channel at any moment, and its id will show up here without any change to this API. Map the three known constants (`dine_in`, `take_away`, `pickup`) explicitly and route everything else through a lookup you can extend — never a `switch` that throws on an unknown value. ## Filtering [Section titled “Filtering”](#filtering) You **must** choose exactly one of two modes per request. Sending both, or neither, is rejected with `400 bad_query` — the API will not guess which one you meant. | | Mode A — calendar range | Mode B — incremental | | ------------ | ----------------------------------- | --------------------------------- | | Parameters | `from` + `to` | `updatedSince` | | Filters on | `billDate` | `updatedAt` | | Format | `YYYYMMDD`, **no dashes** | ISO-8601 datetime | | Maximum span | **31 days**, inclusive | unlimited | | Order | `billDate` ascending | `updatedAt` ascending | | Best for | backfills, month-end reconciliation | continuous sync every few minutes | Common to both: `shopId` is **required** (a shop outside your key’s scope gives `403 shop_not_in_scope`), `limit` defaults to `100`, and `cursor` continues the previous page. Caution Requests use `YYYYMMDD` with no dashes (`20260901`); responses use `YYYY-MM-DD` with dashes (`2026-09-01`). They are not interchangeable — `from=2026-09-01` is rejected with `400 bad_query`. `limit` is **corrected rather than rejected**: a value above 200 is silently reduced to 200, and a value that is not a positive number (`0`, negative, text) falls back to 100. Check `nextCursor`, not the row count, to know whether you have everything. ### `billDate` vs `businessDate` [Section titled “billDate vs businessDate”](#billdate-vs-businessdate) This distinction is the single most common source of “the numbers don’t match”. > **`businessDate`** is the day the bill counts as **revenue** for the shop. A shop’s trading day ends at its own cut-off time, which is usually after midnight. **`billDate`** is the ordinary calendar day. For a shop that closes its day at 06:00, a bill paid at 02:00 has *tomorrow’s* `billDate` but *yesterday’s* `businessDate`. **Filtering always uses `billDate`.** `businessDate` cannot be filtered on — it is returned so that you can group by it yourself after fetching. That means a reconciliation for one trading day must fetch a slightly **wider** calendar range and then group: 1. Request `from` = the day before, `to` = the day after the trading day you want (three calendar days). 2. Group the returned rows by `businessDate`. 3. Keep only the group you asked for. Note `businessDate` comes from the trading-day stamp the bill carries, or is worked out from the bill time and the shop’s own closing time when that stamp is absent. It is `null` only when neither is possible — the bill has no readable time, or the shop has no closing time configured. Bucket those separately rather than silently dropping them, and fall back to `billDate` only if you have decided that an approximate day is acceptable — the two are different calendars, not two spellings of the same one. ### When `updatedAt` is stamped [Section titled “When updatedAt is stamped”](#when-updatedat-is-stamped) Mode B only ever returns bills whose `updatedAt` moved. It is stamped when: * a bill is closed, * a tax invoice number is issued for it, * it is voided or reversed, * a field on it is edited, * a member claims the bill, * a note is written on it. It is **deliberately not** stamped when the bill’s accounting-export status changes — that is not a change to the bill’s contents, and stamping it would make bills reappear in your sync with nothing different about them. Caution **Bills closed before this API existed have no `updatedAt` at all and will never appear in Mode B.** Always run a Mode A backfill first (31 days at a time, walking backwards as far as you need), and only then switch to `updatedSince` for the ongoing feed. Starting with Mode B on a fresh integration will silently give you a partial history. ### Voided bills come back too [Section titled “Voided bills come back too”](#voided-bills-come-back-too) A voided bill is still returned, with `status: "void"`. It is your job to exclude it from revenue. Because voiding stamps `updatedAt`, a bill you already imported as `completed` will come back through Mode B as `void` — **upsert by `id`**, do not append. ## Items [Section titled “Items”](#items) Each entry in `items[]`: | Field | Type | Nullable | Meaning | | ----------- | --------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | **yes** | Product id — join key to your own catalogue | | `rootId` | string | **yes** | Parent product id, for variants and items inside a set | | `name` | string | **yes** | Product name as printed on the bill | | `qty` | number | **yes** | Quantity | | `unitPrice` | number | **yes** | Price per unit recorded on the line. Where the POS recorded none it is derived as line total ÷ quantity, and is `null` when even that is not possible (no readable quantity, quantity `0`, or no line total). | | `lineTotal` | number | **yes** | Total for the line | | `options` | array | no (may be `[]`) | Chosen options: `{ "choiceId", "choiceName", "qty" }`; `qty` defaults to `1` | | `category` | array of string | **yes** | Category the product sat in **at the time of sale**, as category **ids** ordered from the top-level category down to the most specific one. Most lines carry one id; `[]` when the product had no category | | `cancelled` | boolean | no | `true` when the line was cancelled during ordering | | `vat` | boolean | no | Whether the line is subject to VAT | | `isTip` | boolean | no | `true` when the line is a tip, not a product | Tip `category` holds ids, not names — ids stay the same when staff rename a category. Turn them into names with the branch’s `categories` from [`GET /v1/shops`](/core/shops/#categories); an id with no match there belongs to a category the shop has since deleted. It is also a snapshot taken at the moment of sale. If the shop later moves a product to a different category, historical bills keep the old one — which is what you want for period-over-period reporting, and something to be aware of if you also join against a live catalogue. Lines with `cancelled: true` and lines with `isTip: true` are both present in the array. Filter them out before you compute item-level sales. There is **no SKU and no barcode** on the line. Use `items[].id` as the join key against your own product catalogue. Discounts are **not distributed across lines**. `lineTotal` is a gross line value; every discount lives at bill level in `amounts`. If you need discount-adjusted item revenue, allocate `amounts.discountTotal` across lines yourself using a rule you choose. ## Amounts and tax [Section titled “Amounts and tax”](#amounts-and-tax) All values are in **Thai baht (THB)**. | Field | Type | Nullable | Meaning | | --------------- | ------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `subtotal` | number | **yes** | Sum of the item lines before discounts and fees, **with tip lines left out** — see the note below the table | | `discount` | number | **yes** | Manual discount entered by the cashier | | `campaign` | array | no (may be `[]`) | Promotions applied, one row per promotion — see [`campaign[]` and `coupon[]`](#campaign-and-coupon) | | `coupon` | array | no (may be `[]`) | Coupons redeemed, one row per coupon — same row shape | | `discountTotal` | number | **yes** | Total discount **recorded on the bill** — manual + campaigns + coupons. Not capped at the amount due — see the note below the table | | `serviceCharge` | number | **yes** | Service charge | | `deliveryFee` | number | **yes** | Delivery fee charged to the customer | | `cardSurcharge` | number | **yes** | Card surcharge | | `credit` | number | **yes** | Store credit consumed on this bill | | `vat` | number | **yes** | VAT amount | | `vatType` | string | **yes** | VAT mode — see below | | `rounding` | number | **yes** | Rounding adjustment | | `tip` | number | **yes** | Tip | | `net` | number | **yes** | What the customer actually paid — **tip included** | ### `campaign[]` and `coupon[]` [Section titled “campaign\[\] and coupon\[\]”](#campaign-and-coupon) Both lists share one row shape. A bill with no promotion or no coupon carries `[]`, never `null`. | Field | Type | Nullable | Meaning | | -------- | ------ | -------- | ---------------------------------------------------------------------------------- | | `id` | string | **yes** | Identifier of the promotion or coupon, as the shop configured it | | `name` | string | **yes** | Readable name of the promotion or coupon | | `amount` | number | no | Discount this row contributed, in THB. `0` when the row carried no discount amount | ### `vatType` [Section titled “vatType”](#vattype) | Value | Meaning | | ----------- | ----------------------------------------------------------------------------- | | `"Include"` | Menu prices already contain VAT; the `vat` amount is extracted from the total | | `"Exclude"` | VAT is added on top at the end of the bill | | `""` | The shop has never configured VAT | | `null` | The bill carries no VAT setting | Caution **`net` includes `tip`, and a tip is not the shop’s revenue.** To get shop revenue, subtract `amounts.tip` from `amounts.net`. Skipping this is the second most common reason a reconciliation is off by a small, stubborn amount. Two more places where these numbers will not line up the way they look: `subtotal` already leaves tip lines out, while `items[]` still carries the tip row with `isTip: true` — so on a bill with a tip, `subtotal` does not equal the sum of `lineTotal`. And `discountTotal` is what the bill recorded, not what could be taken off: on a bill where the discounts add up to more than the bill itself, `discountTotal` is larger than the amount actually deducted. Note `null` and `0` mean different things in `amounts`. `0` means the feature is on and the value happened to be zero; `null` means the shop does not use that feature at all. Coerce with care — `amount ?? 0` is usually right for summing, but do not report “0 delivery fees collected” for a shop that does no delivery. **No cost data is ever returned.** Cost per line, total cost, recipes and ingredient consumption are excluded from this API by policy, at every key level. Margin cannot be computed from this data alone. ## Payments [Section titled “Payments”](#payments) `payments[]` records how the bill was settled. A single bill can be split across several methods, so this is an array: | Field | Type | Nullable | Meaning | | -------- | ------ | -------- | ------------------------------------------------------------------------------------------------------ | | `name` | string | **yes** | The payment channel’s name, as the shop configured it — e.g. `"เงินสด"`, `"โอน"`, `"บัตรเครดิต"` | | `amount` | number | **yes** | Amount **tendered** through that channel, in THB — what the customer handed over, not what was settled | | `change` | number | **yes** | Change given back on this payment, in THB. `0` on methods that give none | Caution `name` is **free text defined by each shop**, not a fixed code list. Two shops will spell “cash” differently, and a shop can rename a channel at any time. If you need normalised payment types, build a per-shop mapping table and treat an unmapped name as “other” rather than dropping the row. The internal payment record id is not returned. Because `amount` is what the customer handed over, the sum of `payments[].amount` can exceed `amounts.net` when cash was paid and change given back. What actually settled on a row is `amount - change`, and it is those figures that add up to `amounts.net`. ## Voided and refunded bills [Section titled “Voided and refunded bills”](#voided-and-refunded-bills) **ScanFood has no separate refund object.** A refund at the counter is performed by voiding the bill. That means: * `status` has exactly two values: `"completed"` and `"void"`. * There is no partial-refund concept and no negative bill. * `voidReason` carries whatever the staff member typed. * Voiding stamps `updatedAt`, so the change reaches an incremental sync. Your importer should therefore: 1. Upsert on `id` — never insert blindly. 2. Recompute period totals excluding `status: "void"` rather than assuming a bill you imported yesterday is still valid. 3. Keep voided rows rather than deleting them, so that a restated day can be explained. ## Keeping in sync [Section titled “Keeping in sync”](#keeping-in-sync) ### Daily reconciliation [Section titled “Daily reconciliation”](#daily-reconciliation) Pull one trading day and group by `businessDate`. * curl ```bash # Fetch a 3-day calendar window around the trading day 2026-09-01, # then group by businessDate on your side. curl -s -G https://api.scanfood.co/ext/v1/transactions \ -H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \ --data-urlencode "shopId=sh7Kq2mVbN4tRxZ0Lp8W" \ --data-urlencode "from=20260831" \ --data-urlencode "to=20260902" \ --data-urlencode "limit=200" ``` * JavaScript ```js const BASE = 'https://api.scanfood.co/ext/v1'; const KEY = process.env.SCANFOOD_API_KEY; async function getPage(params) { const url = new URL(`${BASE}/transactions`); for (const [k, v] of Object.entries(params)) { if (v != null) url.searchParams.set(k, v); } const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error}: ${body.message}`); return body; } /** Every bill in a calendar range, following the cursor to the end. */ async function fetchRange(shopId, from, to) { const rows = []; let cursor = null; do { const page = await getPage({ shopId, from, to, limit: 200, cursor }); rows.push(...page.data); cursor = page.nextCursor; } while (cursor); return rows; } // Trading day 2026-09-01 — fetch a wider calendar window, then group. const bills = await fetchRange('sh7Kq2mVbN4tRxZ0Lp8W', '20260831', '20260902'); const day = bills.filter( (b) => b.businessDate === '2026-09-01' && b.status === 'completed', ); const revenue = day.reduce((sum, b) => sum + ((b.amounts.net ?? 0) - (b.amounts.tip ?? 0)), 0); console.log('2026-09-01 revenue (tips excluded):', revenue.toFixed(2), 'THB'); ``` * Python ```python import os import requests BASE = "https://api.scanfood.co/ext/v1" HEADERS = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"} def get_page(params): query = {k: v for k, v in params.items() if v is not None} res = requests.get(f"{BASE}/transactions", headers=HEADERS, params=query, timeout=60) body = res.json() if res.status_code != 200: raise RuntimeError(f"{res.status_code} {body['error']}: {body['message']}") return body def fetch_range(shop_id, date_from, date_to): """Every bill in a calendar range, following the cursor to the end.""" rows, cursor = [], None while True: # "from" is a Python keyword, so the query is built as a dict. page = get_page({ "shopId": shop_id, "from": date_from, "to": date_to, "limit": 200, "cursor": cursor, }) rows.extend(page["data"]) cursor = page["nextCursor"] if not cursor: return rows # Trading day 2026-09-01 — fetch a wider calendar window, then group. bills = fetch_range("sh7Kq2mVbN4tRxZ0Lp8W", "20260831", "20260902") day = [b for b in bills if b["businessDate"] == "2026-09-01" and b["status"] == "completed"] revenue = sum((b["amounts"]["net"] or 0) - (b["amounts"]["tip"] or 0) for b in day) print(f"2026-09-01 revenue (tips excluded): {revenue:.2f} THB") ``` ### Sync every 15 minutes [Section titled “Sync every 15 minutes”](#sync-every-15-minutes) Store the highest `updatedAt` you have imported and pass it back as `updatedSince` on the next run. Because the feed is ordered by `updatedAt` ascending, re-sending the last stored value is safe: you will re-receive the boundary bill and upsert it over itself. * curl ```bash curl -s -G https://api.scanfood.co/ext/v1/transactions \ -H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \ --data-urlencode "shopId=sh7Kq2mVbN4tRxZ0Lp8W" \ --data-urlencode "updatedSince=2026-09-01T06:15:00.000Z" \ --data-urlencode "limit=200" ``` * JavaScript ```js /** * Incremental pull. `since` is the highest updatedAt already imported; * pass null on the very first run *after* a from/to backfill. */ async function syncSince(shopId, since) { let cursor = null; let highest = since; do { const page = await getPage({ shopId, updatedSince: since, limit: 200, cursor }); for (const bill of page.data) { upsertBill(bill); // your storage — keyed on bill.id if (bill.updatedAt && (!highest || bill.updatedAt > highest)) { highest = bill.updatedAt; } } cursor = page.nextCursor; } while (cursor); return highest; // persist this, use it as `since` next run } const watermark = await syncSince('sh7Kq2mVbN4tRxZ0Lp8W', loadWatermark()); saveWatermark(watermark); ``` * Python ```python def sync_since(shop_id, since): """Incremental pull. `since` is the highest updatedAt already imported; run a from/to backfill before the first incremental run.""" cursor, highest = None, since while True: page = get_page({ "shopId": shop_id, "updatedSince": since, "limit": 200, "cursor": cursor, }) for bill in page["data"]: upsert_bill(bill) # your storage — keyed on bill["id"] stamp = bill.get("updatedAt") if stamp and (highest is None or stamp > highest): highest = stamp cursor = page["nextCursor"] if not cursor: return highest # persist, use as `since` next run watermark = sync_since("sh7Kq2mVbN4tRxZ0Lp8W", load_watermark()) save_watermark(watermark) ``` Tip Run the incremental job per `shopId`, and keep one watermark per shop. A franchise key can hold dozens of branches; a single shared watermark would make one busy branch drag the others forward past bills they had not yielded yet. ### One bill by id [Section titled “One bill by id”](#one-bill-by-id) ```bash curl -s https://api.scanfood.co/ext/v1/transactions/tx9Bd2LmQ7sVfR4KpN1C \ -H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" ``` A bill that does not exist and a bill belonging to a shop outside your key both return the same `404 not_found` — the API will not confirm that an id exists outside your scope. ## Example response [Section titled “Example response”](#example-response) ```json { "ok": true, "data": [ { "id": "tx9Bd2LmQ7sVfR4KpN1C", "orderIds": ["or4Tq8ZmB2vNs6WkY9Hd", "orH5nP2wQ8tLcZ3RvK7M"], "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", "franchiseId": null, "shopId": "sh7Kq2mVbN4tRxZ0Lp8W", "deviceId": "dv6Mk1PqR8tZbN3WxL5J", "stationId": "st2Wc7YnV5qLpK8ZmR4T", "channel": { "code": "ch4Rn9WsT2kLqZ6BvY8M", "name": "แกร็บฟู้ด" }, "serviceType": "alacarte", "tableName": null, "memberId": "m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM", "items": [ { "id": "pd8Zt3QvL6mNc1KwB9Ys", "rootId": "rt1Kp5MwZ9bQn7VxL3Cd", "name": "สปาเก็ตตี้ขี้เมา", "qty": 3, "unitPrice": 137.55, "lineTotal": 412.65, "options": [], "category": ["9369601f-fef5-4a61-bfb8-26f25eab3dad"], "cancelled": false, "vat": true, "isTip": false } ], "amounts": { "subtotal": 412.65, "discount": 20, "campaign": [], "coupon": [], "discountTotal": 20, "serviceCharge": 0, "deliveryFee": 0, "cardSurcharge": 0, "credit": 0, "vat": 0, "vatType": "Include", "rounding": 0, "tip": 0, "net": 392.65 }, "payments": [{ "name": "เงินสด", "amount": 400, "change": 7.35 }], "status": "completed", "voidReason": null, "cashier": "สมชาย ใจดี" } ], "nextCursor": null, "serverTime": "2026-09-01T06:30:00.000Z" } ``` ## What is never returned [Section titled “What is never returned”](#what-is-never-returned) By policy, and enforced by the shape of the response itself: | Category | Not available | | ------------------- | ------------------------------------------------------------------------------- | | Cost | cost per line, total cost, recipes, ingredient consumption, stock movements | | Customer identity | customer name, member name, phone, email, address, tax-invoice customer details | | Evidence and notes | transfer slips and payment evidence images, bill notes, uploaded images | | Staff traces | staff codes, the staff member who voided, shift and manager references | | Shop operations | delivery tracking numbers, pickup details, per-line staff assignment | | Internal references | payment gateway reference ids, accounting-export state, edit metadata | If a report needs any of these, it cannot be produced from this API — say so early rather than designing around a field that will never arrive. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * [Members](/core/members/) — resolve `memberId` on a bill to a member record. * [Pagination and sync](/get-started/pagination-and-sync/) — cursor rules in detail. * [Time zones and business date](/get-started/timezone-and-business-date/). * [Errors](/get-started/errors/) — every code this endpoint can return. * [Rate limits](/get-started/rate-limits/) — how fast you can pull. # Authentication > How API keys work, what each key can see, and how to keep keys safe. Every endpoint except `GET /v1/openapi.json` requires an API key. One key carries both your identity and your permissions: what it may read is fixed when it is issued and can never be widened by anything you put in a request. ## The Authorization header [Section titled “The Authorization header”](#the-authorization-header) Send the key as a bearer token: ```http Authorization: Bearer sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` * curl ```bash curl -s https://api.scanfood.co/ext/v1/shops \ -H "Authorization: Bearer $SCANFOOD_API_KEY" ``` * JavaScript ```js const res = await fetch('https://api.scanfood.co/ext/v1/shops', { headers: { Authorization: `Bearer ${process.env.SCANFOOD_API_KEY}` }, }); ``` * Python ```python headers = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"} r = requests.get("https://api.scanfood.co/ext/v1/shops", headers=headers, timeout=30) ``` The header is the only accepted channel. Keys in a query string or a request body are ignored and the call is answered as if no key had been sent — query strings end up in the access logs of every intermediary, and bodies end up in tracing systems. A key has three parts: | Part | Example | Notes | | ------ | ------------------------- | ----------------------------------------- | | Prefix | `sf_live_` | Production keys only. | | Key id | 12 hexadecimal characters | Identifies the key; safe to log. | | Secret | 32 hexadecimal characters | Never log it. ScanFood keeps only a hash. | Server to server only Cross-origin requests are refused, so this API cannot be called from a web page or a mobile app — and that is deliberate. A key that reaches a user’s device is a key that has leaked. Put the key on a server you control and let your own front end talk to that server. ## Key scope [Section titled “Key scope”](#key-scope) Scope is decided when the key is issued, and every request is checked against it. Nothing in a request can change it. | Scope | Bound to | Which shops it reads | Which member brands it reads | | ----------- | ------------- | ------------------------------ | ----------------------------------------- | | `shop` | One shop | That shop only | The member brand attached to that shop | | `franchise` | One franchise | Every branch of that franchise | Every member brand used by those branches | The practical consequences: * `GET /v1/shops` takes no parameters. It returns the branches the key may read, which makes it the fastest way to see what a key is allowed to do. A branch that has since been deleted drops out of this list while staying inside the key’s scope, so read the list as “what is there to read”, not as the literal definition of the scope. * Passing a `shopId` outside the scope returns `403 shop_not_in_scope`, and a `providerId` outside the scope returns `403 provider_not_in_scope`. The list endpoints answer with 403 rather than 404 because the id you sent is one you already knew. * Asking for a single transaction or member outside the scope returns `404 not_found` instead — we do not confirm that an id exists in a shop you cannot read. A second flag, set per key, decides whether member **phone numbers and e-mail addresses** are returned. It is off by default; without it you still get `hasTel` and `hasEmail` booleans, which are enough to know whether a member can be contacted at all. ## Rotating a key [Section titled “Rotating a key”](#rotating-a-key) Keys do not expire on their own, so rotation is something you schedule, not something the API forces on you. Because the key is only ever shown once, plan the change before you make it: 1. Ask ScanFood for a **second** key with the same scope. 2. Deploy it to your systems and confirm traffic is flowing on the new key. 3. Ask for the old key to be revoked. Rotate without downtime Keys are independent of one another, so both work at the same time during step 2. Never revoke first: the old key stops working before the new one is live and every job in between fails. Store the key where your deployment already keeps secrets. Do not commit it, do not paste it into a ticket, and do not e-mail it. If it does leak, treat that as a rotation: issue a new key, deploy, revoke the old one. ## Revoking a key [Section titled “Revoking a key”](#revoking-a-key) Revocation is permanent — a revoked key can never be re-enabled, and a new one must be issued instead. Revoking a key that is already revoked is harmless. Requests made with a revoked key are answered `401 key_revoked`. That is a different code from `invalid_key` on purpose: only the holder of a correct secret can ever see it, so it tells you *“this key was retired”* rather than sending you off to hunt for a typo. Revocation takes up to 5 minutes Keys are cached for five minutes for speed, so a revoked key may keep working for up to five minutes after the change. Plan for that window when you retire a key — and if a key has genuinely leaked, tell us so we can act rather than waiting it out. ## Common authentication errors [Section titled “Common authentication errors”](#common-authentication-errors) | Status | `error` | When it happens | What to do | | ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `401` | `invalid_key` | No header, a malformed header, a key id that does not exist, or a wrong secret — all four give the same answer | Check that the header is exactly `Authorization: Bearer sf_live_…`, with no extra whitespace and no quotes | | `401` | `key_revoked` | The key was retired | Move to the replacement key | | `503` | `auth_unavailable` | ScanFood could not verify the key right now (`Authentication service temporarily unavailable, retry later`). This is our side, not yours | Retry with backoff; do not touch your key | `401` responses carry no quota headers, because the key is checked before the quota is. See [Rate limits](/get-started/rate-limits/) for what the headers mean when they do appear, and [Errors](/get-started/errors/) for every other code. # Data model & IDs > How shops, transactions, items and members relate, and which identifier to store. Four identifiers hold this API together: a franchise, a shop, a member brand and a bill. Get those right and everything else — devices, items, coupons, channels — hangs off them. ## Object map [Section titled “Object map”](#object-map) ```plaintext franchiseId ─┬─ shopId ─┬─ transaction id ──── items[] │ │ │ ├─ deviceId · stationId (on every bill) │ │ │ └─ providerId ─── memberId (m1_…) ─── coupons[] │ ▲ └─ … other branches │ a bill with a member ─┘ (transaction.memberId is the same value) ``` * **`GET /v1/shops` is always the starting point.** It returns `shopId`, `franchiseId` and `providerId` for every branch your key can read, and no other call needs those relationships explained. * **`franchiseId` and `providerId` are different axes.** Both are recorded on the shop: one is the business structure, the other is the loyalty database. Several branches can share one member brand without belonging to the same franchise, so never derive one from the other. * **A transaction belongs to exactly one shop**, and carries the `franchiseId`, `deviceId` and `stationId` with it — you can total across branches or split by till without a second call. * **A member belongs to a brand, not to a branch.** Member records are scoped by `providerId`; there is no shop on them. ## Identifiers [Section titled “Identifiers”](#identifiers) | Identifier | Where you get it | Shape | Use it for | | ------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `franchiseId` | `GET /v1/shops`, and on every transaction | Opaque string, `null` for an independent shop | Grouping branches | | `shopId` | `GET /v1/shops` | Opaque string | Required by `GET /v1/transactions` | | `providerId` | `GET /v1/shops` | Opaque string, `null` if the shop runs no loyalty programme | Required by `GET /v1/members` | | Transaction `id` | `data[].id` on the transactions list | Opaque string | `GET /v1/transactions/{id}`, and as the cursor on that endpoint | | `deviceId`, `stationId` | On every transaction | Opaque string or `null` | Splitting sales by till | | `memberId` | On a member, on any bill paid by one, and from `GET /v1/members/lookup` | `m1_` + an encoded token | `GET /v1/members/{memberToken}`, and joining bills to members | | `items[].category`, `categories[].id` | Inside a transaction; the shop’s list on `GET /v1/shops` | Opaque string | Turning a sold line’s category ids into names — match on `id` within the same `shopId`. An id with no match is a deleted category | | `items[].id`, `items[].rootId` | Inside a transaction | Opaque string or `null` | Joining a sold line to your own catalogue. `rootId` links a branch’s menu item back to the head-office master product of a franchise; an independent shop leaves it `null`. It is not a variant or set-item parent | Every one of these is opaque: treat it as a string, keep its exact length and case, and never build one yourself or infer meaning from its characters. The member token deserves a note of its own. It is not the internal member record id — that identifier is derived from the person’s messaging account, so it is never handed out. The token is: * **Stable.** The same member always produces the same token, so it is safe to store as a foreign key, use as a list key, or match against bills. * **Global.** The token does not change between keys or branches, so two shops in the same franchise see the same value for the same person. * **One-way.** It cannot be turned back into a real identity outside ScanFood. * **Versioned by its prefix.** Today every token starts with `m1_`. A future key rotation would produce `m2_` tokens, so match the prefix rather than assuming a fixed length. You can take a `memberId` straight off a bill and call `GET /v1/members/{memberToken}` with it — no lookup table required. If that member belongs to a brand your key cannot read, the answer is `404`. There is one other way to obtain a token. If you already hold a customer’s **LINE user id** — from your own bot or LIFF app — `GET /v1/members/lookup` exchanges it for that person’s `memberId`, on keys where that lookup is switched on. The exchange runs one way only: an id goes in, a token comes out, and no response ever carries a LINE user id back. See [Members](/core/members/#looking-a-member-up-from-a-line-user-id). ## Money and amounts [Section titled “Money and amounts”](#money-and-amounts) All amounts are **Thai baht** as plain numbers — no currency field, no minor units, no strings. Bill totals live in `amounts`, and follow four rules: * **`amounts.net` is what the customer paid, tips included.** Subtract `amounts.tip` before you call it shop revenue: a tip passes through to staff. * **`null` is not `0`.** `null` means “this does not apply here” — the feature is switched off at the shop, or the field was never written. `0` means the feature is on and the value happens to be zero. Do not conflate them when you aggregate. * **`amounts.vatType` says how VAT sits in the price.** `"Include"` means prices already contain VAT and it is extracted from the total; `"Exclude"` means VAT is added at the end. Read it before you recompute any tax figure. * **Discounts are recorded at bill level, not per line.** `amounts.discount` is what the cashier keyed in by hand, `amounts.campaign` and `amounts.coupon` list the promotions and coupons that applied, and `amounts.discountTotal` is what actually came off. Line items carry no discount column, so if you need a per-line net you have to apportion it yourself. Line items carry `qty`, `unitPrice` (the price actually charged, after any line-level adjustment), `lineTotal`, the options chosen, the category ids at the time of sale, and flags for `cancelled`, `vat` and `isTip`. A tip that was rung up as a line shows as `isTip: true` — exclude those lines from product analytics. `payments[]` breaks the settlement down by method, because one bill can be split across several. The amounts there can exceed `net` when cash was tendered and change was given back. ## Channels [Section titled “Channels”](#channels) `channel` tells you how the order reached the kitchen: | `code` | Meaning | | ----------------- | -------------------------------------------------------------------------------- | | `dine_in` | Eaten in the restaurant. A counter sale rung up without a table lands here too | | `take_away` | Rung up at the counter to be taken away | | `pickup` | Ordered by the customer for collection, typically through a QR code or link | | A shop-defined id | A channel the shop configured itself — delivery apps, marketplaces, phone orders | | `null` | The bill has no channel recorded | `channel.code` is an open set Shops add their own channels whenever they want, so `code` is not a closed enumeration and new values appear without a version change. Map the three known values, and treat anything else as a shop-defined channel — with `channel.name` as the label to show. Never fail on an unrecognised code. ## Fields you should not depend on [Section titled “Fields you should not depend on”](#fields-you-should-not-depend-on) Some things are deliberately absent, and some are present but unstable. **Never returned, by policy:** * **Costs and recipes** — unit costs, ingredient formulas and stock deductions stay inside ScanFood. * **Customer identity on a bill** — names, phone numbers, addresses, receipt notes and uploaded slips are not part of a transaction. * **Member identity** — a member’s name, date of birth, gender, photos, raw point history and visit history are never returned. You get `hasTel`, `hasEmail` and `hasLine` booleans; the phone number and e-mail themselves are only unlocked on a key that has been explicitly approved for them. * **Internal operational fields** — shift and manager references, payment gateway references and accounting-system status. **Present, but do not build logic on it:** * **`message` text in errors.** Branch on the `error` code instead. * **The number of keys in an object.** Fields get added within `/v1/`; ignore what you do not recognise. * **`channel.name`, `tableName` and `cashier`.** These are what shop staff typed, and staff rename things. Join on ids, display the names. * **`rankLevel`.** It only carries meaning when the brand runs automatic rank promotion; use `rankName` for display. * **`businessDate`** can be `null` on bills that were not closed through the current POS path — see [Time zone & business date](/get-started/timezone-and-business-date/). # Errors > The error envelope, the status codes we return, and how to react to each one. Every failure comes back as JSON with the same shape, whatever went wrong. The HTTP status tells you the class of problem; the `error` field tells you exactly which one. ## Error shape [Section titled “Error shape”](#error-shape) ```json { "ok": false, "error": "shop_not_in_scope", "message": "this API key cannot access that shopId" } ``` | Field | Always present | Notes | | --------------- | ---------------------- | ------------------------------------------------------------------- | | `ok` | Yes | Always `false` on an error. Successful responses carry `"ok": true` | | `error` | Yes | A stable, machine-readable code. **Branch on this** | | `message` | In practice, yes | Human-readable, in English. May be reworded at any time | | `retryAfterSec` | Only on `rate_limited` | Seconds to wait. That response also carries `limit` and `windowSec` | Never match on the message `message` exists for the person reading your logs, not for your code. It is not part of the contract: wording can change without notice. The `error` code is the stable half. ## Status codes [Section titled “Status codes”](#status-codes) | Status | Meaning | Is it worth retrying? | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | `200` | Success. An empty `data` array is still a success | — | | `400` | Your request is malformed: a missing or contradictory parameter, or a dead cursor | No — fix the request | | `401` | The key is missing, wrong or revoked | No — fix the key | | `403` | The key is valid, but the shop or member brand you asked for is outside its scope — or the call needs a per-key permission the key was not granted | No — ask for an id the key can read, or for the permission | | `404` | The transaction or member does not exist, **or** is outside your scope | No | | `429` | Too many requests this minute, or the daily quota is gone | Yes, after waiting | | `500` | Something failed on our side | Yes, with backoff | | `503` | Key verification is temporarily unavailable on our side | Yes, with backoff | Note the deliberate asymmetry between `403` and `404`. Listing endpoints answer `403` because you already knew the `shopId` or `providerId` you sent. Single-item endpoints answer `404` for both “no such bill” and “a bill in a shop you cannot read”, so that the response never confirms whether an id exists somewhere else in ScanFood. ## Error codes [Section titled “Error codes”](#error-codes) | Status | `error` | When it happens | | ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `bad_query` | `shopId is required` — the transactions list needs one | | `400` | `bad_query` | `providerId is required` — the members list needs one | | `400` | `bad_query` | `choose exactly one mode: from+to (calendar days) or updatedSince (ISO-8601)` — you sent both, or neither | | `400` | `bad_query` | `updatedSince must be an ISO-8601 datetime` — the value is parsed leniently today, so a loose format such as `2026/09/01` may slip through. Send real ISO-8601 anyway; that is the format we support | | `400` | `bad_query` | `from/to must be YYYYMMDD` — requests use no dashes | | `400` | `bad_query` | `from must be <= to` | | `400` | `bad_query` | `range too wide: N days (max 31)` | | `400` | `bad_query` | `id is required` — no transaction id in the path | | `400` | `bad_query` | The member lookup was called without a `providerId`, or with a `lineUserId` that is not `U` followed by 32 hexadecimal characters | | `400` | `bad_cursor` | `cursor no longer exists` — the row it pointed at is gone | | `400` | `bad_cursor` | `cursor is not a valid member token` — you sent something that is not a `m1_…` token | | `400` | `not_supported` | `updatedSince` on the members list. Incremental member sync is not open yet; page through with `cursor` instead | | `401` | `invalid_key` | `Invalid API key` — missing, malformed, unknown or wrong. All four answer identically | | `401` | `key_revoked` | `API key has been revoked` | | `403` | `shop_not_in_scope` | `this API key cannot access that shopId` | | `403` | `provider_not_in_scope` | `this API key cannot access that providerId` | | `403` | `lookup_not_enabled` | Member lookup is not switched on for this key. It is granted per key, like `tel` and `email` — see [Members](/core/members/#switched-on-per-key) | | `404` | `not_found` | `transaction not found` — unknown id, or a shop outside your scope | | `404` | `not_found` | `member not found` — unknown or unreadable token, or a brand outside your scope | | `404` | `not_found` | No member matched a LINE user id you looked up. One answer for three situations: not a member of this brand, a member of another brand, or nobody at all | | `429` | `rate_limited` | `Too many requests, retry in about N seconds` — more than the per-minute burst. Body carries `retryAfterSec`, `limit`, `windowSec`; the header `Retry-After` says the same | | `429` | `quota_exceeded` | `daily quota exceeded (N requests/day, Thailand time). Resets at 00:00 ICT.` | | `500` | `internal` | `temporary failure, please retry` — our side | | `503` | `auth_unavailable` | `Authentication service temporarily unavailable, retry later` — key verification is temporarily unavailable. We answer `503` rather than `401` so you do not go hunting through a key that is perfectly fine | ## Retrying safely [Section titled “Retrying safely”](#retrying-safely) Every endpoint is a `GET` that changes nothing a shop owns, so a retry can never duplicate a bill or double-apply anything. The trace a repeated call leaves is one more tick on your quota and one more row in our request log, which we keep for 90 days. A retry policy that works: | Situation | Policy | | --------------------------------------- | ------------------------------------------------------------------------------------ | | `429 rate_limited` | Wait `Retry-After` seconds, then retry. Do not shorten the wait | | `429 quota_exceeded` | Stop until 00:00 ICT, or ask for a bigger quota. Retrying sooner just burns requests | | `500`, `503`, connection reset, timeout | Exponential backoff with jitter — for example 1s, 2s, 4s, 8s, up to five attempts | | `400`, `401`, `403`, `404` | Do not retry. Nothing about the same request will succeed a second time | Two more habits worth having: * **Log the `error` code, the status and the request parameters** — but never the key. The 12-character key id is safe to log; the secret half is not. * **Handle a dead cursor as a restart, not as a failure.** `bad_cursor` means the row your cursor pointed at is gone. Drop the cursor and re-request that page from the start of the range; see [Pagination & sync](/get-started/pagination-and-sync/). # Introduction > What the ScanFood API gives you, who it is for, and what it does not do. The ScanFood API lets your own systems read the data a ScanFood point of sale already records — shops, closed transactions and loyalty members. It is read-only, authenticated with a single API key, and designed to be polled on a schedule rather than to drive the till. Every request goes to one base URL: ```plaintext https://api.scanfood.co/ext ``` The version is part of each endpoint path, so a full URL reads `https://api.scanfood.co/ext/v1/shops`. ## What you can build [Section titled “What you can build”](#what-you-can-build) * **Accounting and tax exports** — pull yesterday’s closed bills, with receipt and tax invoice numbers, VAT and payment breakdowns, into your bookkeeping system. * **Reporting and BI** — load transactions into a warehouse and slice by shop, device, sales channel or product. * **Franchise consolidation** — one franchise-scoped key reads every branch, so head office can total the group without asking each shop for a file. * **Loyalty and CRM** — read members, their points, store credit, rank and coupons, and match them to the bills they appear on. ## What the API covers [Section titled “What the API covers”](#what-the-api-covers) | Resource | Endpoint | What it returns | | ------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | Shops | `GET /v1/shops` | Every branch this key may read, with its time zone and member provider | | Transactions | `GET /v1/transactions` · `GET /v1/transactions/{id}` | Closed bills for one shop: line items, amounts, payments, status | | Members | `GET /v1/members` · `GET /v1/members/{memberToken}` · `GET /v1/members/lookup` | Loyalty members of one brand: points, credit, rank, coupons, visits — and the member token behind a LINE user id you already hold | | Specification | `GET /v1/openapi.json` | The machine-readable contract — the only endpoint that needs no key | Two properties hold everywhere: * **Read-only for your data.** Every endpoint is a `GET`. No request creates, changes or deletes anything a shop owns, so a call can always be repeated safely. We do record the calls themselves: the key’s last-used time, a daily counter for your quota, and a request log — key id, path, query string, status, row count and timing — kept for 90 days for support and abuse handling. The secret half of your key is never stored. * **Pull, not push.** You decide when to ask. A common cadence is every 15 minutes for transactions and once a day for members. ## What it does not do [Section titled “What it does not do”](#what-it-does-not-do) Being explicit about the edges saves you a support round-trip: * **No writes.** You cannot create, void or amend a bill, or change a member. * **No webhooks or push notifications.** Nothing calls your servers; you poll. * **No calls from a browser.** Cross-origin requests are refused on purpose, because a key that reaches a browser is a key that has leaked. * **No product catalogue, stock or SKU endpoints.** Line items carry a product id you can join against your own catalogue, but there is no SKU or barcode. * **No cost or recipe data.** Unit costs and ingredient formulas never leave ScanFood, by policy. * **No separate refund object.** A refund at the counter is a voided bill — the transaction comes back with `status: "void"`. * **No pre-computed loyalty segments.** You get visit counts, last visit and the underlying bills; the segmentation is yours to define. * **No separate test environment.** Keys are live keys and read your own real data — see [Test data](/before-you-go-live/test-data/) for how to try things out safely. * **Incremental sync for members is not open yet.** Transactions support `updatedSince`; members must be re-read in full. See [Pagination & sync](/get-started/pagination-and-sync/). ## How the API is versioned [Section titled “How the API is versioned”](#how-the-api-is-versioned) The version lives in the path: every endpoint today is under `/v1/`. Within a version we may **add** fields to a response or accept **new optional** parameters; neither breaks a client that ignores what it does not recognise. To stay compatible with additive changes: * Read fields by name; never rely on the order or the number of keys. * Branch on the `error` code, never on the human-readable `message` — message text can change at any time. * Treat enumerations like `channel.code` as open strings; shops add their own sales channels whenever they like. A breaking change would ship as a new path prefix, not as a silent edit of `/v1/`. The current contract is always downloadable from `GET /v1/openapi.json`, which needs no API key. ## Next steps [Section titled “Next steps”](#next-steps) * [Quickstart](/get-started/quickstart/) — your first authenticated call. * [Authentication](/get-started/authentication/) — how keys and scope work. * [Data model & IDs](/get-started/data-model-and-ids/) — how the identifiers fit together. * [API reference](/api/) — every endpoint, parameter and field. # Pagination & sync > Walk a result set with the cursor, and keep a local copy in step with the POS. 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”](#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: * **`limit` is 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 a `400` for 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 with `400 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, no `page` or `offset`. The only stopping condition is `nextCursor === null`. * **`GET /v1/shops` is not paginated at all** — it returns every branch the key can read in a single response. A cursor can go stale A cursor points at a specific row. If that row disappears between two pages you get `400 bad_cursor`. Recover by dropping the cursor and re-requesting the range from the beginning — do not treat it as a fatal error. ## Reading every page [Section titled “Reading every page”](#reading-every-page) The same loop works for transactions and for members; only the parameters differ. * curl ```bash # First page curl -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 received curl -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" ``` * JavaScript ```js 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); } ``` * Python ```python 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”](#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. ```bash 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. Two things `updatedSince` cannot see **Bills closed before this API existed have no change stamp** and never appear in this mode. Take a full copy with `from`/`to` first — see below. **Members do not support `updatedSince` yet.** Sending it returns `400 not_supported`. The reason is honest: roughly three quarters of existing member records were last written before change stamping began, so an incremental read would quietly miss them. Re-read the brand in full instead — paging through with `cursor` once a day is inexpensive. `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”](#backfilling-history) Do this once per shop, then switch to incremental. 1. **List the shops.** `GET /v1/shops` gives you every `shopId` the key can read. 2. **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. 3. **Page each window to the end** with `limit=200`, following `nextCursor`. 4. **Record the `serverTime`** of the final successful window. That timestamp is the starting point for your first incremental run. 5. **Members separately.** Page `GET /v1/members` per `providerId` — one brand may be shared by several branches, so de-duplicate the provider ids you got from step 1 before you start. Run the backfill off-peak A year of history for twenty shops is hundreds of requests. Both the per-minute burst and the daily quota are counted per key, so spread the work and leave room for the live sync — see [Rate limits](/get-started/rate-limits/). ## Idempotency on your side [Section titled “Idempotency on your side”](#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 `id`** for transactions, and on `memberId` for 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 `updatedAt` alongside each bill.** If a row arrives with an older `updatedAt` than the one you hold, it is a replay — ignore it. * **Advance your watermark only after a successful run.** Storing rows and the new `updatedSince` in the same transaction avoids a gap if the job dies halfway. * **Expect `null` values, not missing fields.** Fields that do not apply come back as `null`, so a column that is suddenly empty is data, not a schema change. # Quickstart > Make your first authenticated call and read one day of transactions. 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”](#before-you-start) You need three things: * **An API key** from ScanFood — see [step 1](#step-1--get-an-api-key). * **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. ```bash export SCANFOOD_API_KEY="sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" ``` ## Step 1 — Get an API key [Section titled “Step 1 — Get an API key”](#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: ```plaintext sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` The key is shown once ScanFood stores only a hash of your key, so nobody — including us — can read it back to you later. If you lose it, the only remedy is to issue a new key and revoke the old one. Put it in your secrets manager the moment you receive it. ## Step 2 — Call the API [Section titled “Step 2 — Call the API”](#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 ```bash curl -s https://api.scanfood.co/ext/v1/shops \ -H "Authorization: Bearer $SCANFOOD_API_KEY" ``` * JavaScript ```js 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); ``` * Python ```python import os import 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"]) ``` ```json { "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”](#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 ```bash 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" ``` * JavaScript ```js 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)'); ``` * Python ```python 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](/core/transactions/): ```json { "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](/get-started/pagination-and-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](/get-started/timezone-and-business-date/). | | `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”](#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 of `from`/`to`, and pass the `serverTime` of your previous successful run. * **Members** — `updatedSince` is not available yet, so re-read the brand in full with the cursor. Once a day is plenty. - curl ```bash 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" ``` - JavaScript ```js 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; } ``` - Python ```python 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 rows ``` Backfill before you switch `updatedSince` only sees bills that carry a change stamp. Bills closed before this API existed do not have one and will never appear in that mode. Take a full copy with `from`/`to` first, then switch. Full recipe in [Pagination & sync](/get-started/pagination-and-sync/). ## Troubleshooting [Section titled “Troubleshooting”](#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](/get-started/errors/) page. # Rate limits > How many requests a key may send, and what to do when you are throttled. Two limits protect the platform, and they are counted separately: a burst limit per minute and a quota per day. Both are counted per key, so keys issued to different systems never eat each other’s budget. ## The limit [Section titled “The limit”](#the-limit) | Limit | Default | Window | Applies to | | ----------- | --------------------------- | ----------------------------------------------------- | ---------- | | Burst | **60 requests per minute** | Rolling 60 seconds | Each key | | Daily quota | **10,000 requests per day** | Calendar day in Thailand time, resetting at 00:00 ICT | Each key | These are the defaults every key starts with. Either number can be raised or lowered for an individual key when it is issued. The daily quota that actually applies to your key is returned on every successful request in `X-RateLimit-Limit` — read it there instead of hard-coding it — and the per-minute ceiling is reported as `limit` in the body of a `429 rate_limited` response. Two details that decide whether your budget is enough: * **Requests are counted as they arrive, not as they succeed.** A run of failing calls still spends the quota, so fix a `400` loop rather than letting it run. * **The daily counter resets at midnight Thailand time (UTC+7)**, not at midnight in your own zone. A job that starts at 23:00 in Europe is already running in tomorrow’s quota. ## Rate limit headers [Section titled “Rate limit headers”](#rate-limit-headers) | Header | On which responses | Value | | ----------------------- | -------------------------------------------------------------- | --------------------------------------------- | | `X-RateLimit-Limit` | Every request that gets past the key check and the burst limit | Your key’s **daily** quota | | `X-RateLimit-Remaining` | Same | Requests left in today’s quota after this one | | `Retry-After` | Only on `429 rate_limited` | Seconds to wait before retrying | The headers are not on every response `X-RateLimit-*` describes the **daily** quota only, and it is attached after the burst limit has been cleared. So a `401` (bad key) and a `429 rate_limited` (burst) carry no quota headers at all. On the rare occasion our quota store is unreachable the request is still served, but without these headers — so a `200` can arrive with no `X-RateLimit-*` on it either. Read a missing header as *unknown*, never as zero. There is no header for the per-minute limit and no reset timestamp — use `Retry-After` for the burst and the 00:00 ICT rule for the day. ## Check your own usage [Section titled “Check your own usage”](#check-your-own-usage) The headers above describe the request you just made, and only reach you on the requests that carry them. [`GET /v1/usage`](/api/operations/getusage/) answers the same question outright, whenever you ask: the ceilings your key actually runs under, what it has spent today, and the six days before that. The endpoint takes no parameters. It reports on the key in the `Authorization` header and on nothing else — there is no way to ask about another key. * curl ```bash curl -s -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/usage" ``` * JavaScript ```js const res = await fetch('https://api.scanfood.co/ext/v1/usage', { headers: { Authorization: `Bearer ${process.env.SCANFOOD_API_KEY}` }, }); const { data } = await res.json(); console.log(`${data.today.count} spent, ${data.today.remaining} left today`); ``` * Python ```python r = requests.get("https://api.scanfood.co/ext/v1/usage", headers=HEADERS, timeout=30) r.raise_for_status() usage = r.json()["data"] print(f"{usage['today']['count']} spent, {usage['today']['remaining']} left today") ``` ```json { "ok": true, "data": { "keyId": "9f3a1c7d5b2e", "label": "Nightly warehouse sync", "scope": "shop", "rate": { "perMin": 60, "perDay": 10000 }, "today": { "date": "20260910", "count": 127, "remaining": 9873 }, "days": [ { "date": "20260904", "count": 96 }, { "date": "20260905", "count": 101 }, { "date": "20260906", "count": 0 }, { "date": "20260907", "count": 88 }, { "date": "20260908", "count": 143 }, { "date": "20260909", "count": 132 }, { "date": "20260910", "count": 127 } ] }, "serverTime": "2026-09-10T06:45:12.310Z" } ``` | Field | Meaning | | ----------------- | --------------------------------------------------------------------------------------- | | `keyId` | The key you authenticated with — the part of the key before the secret | | `label` | The label recorded when the key was issued, or an empty string | | `scope` | `shop` for a single branch, `franchise` for every branch under one franchise | | `rate.perMin` | The per-minute ceiling this key runs under | | `rate.perDay` | The daily quota this key runs under — the same number as `X-RateLimit-Limit` | | `today.date` | Today in Thailand time, `YYYYMMDD` | | `today.count` | Requests counted today, including this one | | `today.remaining` | `perDay` minus `count`, never below zero | | `days[]` | The last seven Thailand days, oldest first and today last, each with `date` and `count` | Three things worth knowing before you build on the numbers: * **`today.count` includes the call you just made.** A request is counted as it arrives, so `today.remaining` is exactly the `X-RateLimit-Remaining` header on that same response — and asking for your usage spends one request of the quota like any other call. * **`days` always holds seven entries.** A day with no traffic comes back as `0` rather than being left out, so a chart or a report needs no gap filling. * **This is the one place that reports `perMin` without tripping it.** No header carries the per-minute ceiling; otherwise you only learn it from the `limit` field of a `429 rate_limited` body. The numbers cannot drift `/v1/usage` reads the same counters the quota meter itself enforces, so what you see here is what decides whether the next call is answered or refused with `429 quota_exceeded`. ## Handling 429 [Section titled “Handling 429”](#handling-429) Two different situations share the `429` status, and they need different reactions. Branch on the `error` field: | `error` | Meaning | What to do | | ---------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | | `rate_limited` | Too many requests in the last minute | Wait `Retry-After` seconds and retry. The body also carries `retryAfterSec`, `limit` and `windowSec` | | `quota_exceeded` | The daily quota is gone | Stop for the day, or ask for a larger quota. Retrying will not help before 00:00 ICT | ```json { "ok": false, "error": "rate_limited", "message": "Too many requests, retry in about 37 seconds", "retryAfterSec": 37, "limit": 60, "windowSec": 60 } ``` A retry helper that honours both: * curl ```bash # --retry only understands the header, so let curl wait for you. curl -s --retry 5 --retry-delay 5 --retry-all-errors \ -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/shops" ``` * JavaScript ```js async function call(url, attempt = 0) { const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SCANFOOD_API_KEY}` }, }); if (res.status === 429) { const body = await res.json(); if (body.error === 'quota_exceeded') throw new Error('daily quota exhausted'); const wait = Number(res.headers.get('retry-after') ?? body.retryAfterSec ?? 30); await new Promise((r) => setTimeout(r, wait * 1000)); return call(url, attempt + 1); } if (res.status >= 500 && attempt < 4) { await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); return call(url, attempt + 1); } return res.json(); } ``` * Python ```python import time def call(url, params=None, attempt=0): r = requests.get(url, headers=HEADERS, params=params, timeout=30) if r.status_code == 429: body = r.json() if body.get("error") == "quota_exceeded": raise RuntimeError("daily quota exhausted") wait = int(r.headers.get("Retry-After") or body.get("retryAfterSec") or 30) time.sleep(wait) return call(url, params, attempt + 1) if r.status_code >= 500 and attempt < 4: time.sleep(2 ** attempt) return call(url, params, attempt + 1) r.raise_for_status() return r.json() ``` ## Designing a polling schedule [Section titled “Designing a polling schedule”](#designing-a-polling-schedule) The limits are generous for a sync, and tight for a crawl. Some arithmetic before you write the scheduler: * **Ask for full pages.** `limit=200` is the maximum; a day with 600 bills is 4 requests at 200 and 7 at 100 — 3 (or 6) full pages, plus one closing call, because a page that comes back exactly full always needs one more request to see `nextCursor: null`. * **One shop per request.** A franchise of 20 branches polled every 15 minutes is `20 × 4 × 24 = 1,920` requests a day before paging — comfortable inside 10,000, but worth doing the sum for larger groups. * **Poll incrementally.** `updatedSince` returns only what changed since your last run, so a quiet quarter of an hour costs one request per shop. * **Backfill deliberately.** History is fetched in windows of at most 31 days; a year per shop is at least 12 requests plus paging. Run it once, off-peak, not on every deploy. * **Spread the branches out.** Twenty shops fired at the same second is a burst; the same twenty spread across the minute is not. If a legitimate workload does not fit, ask for a higher quota rather than splitting one system across several keys — separate keys make usage impossible to read back. # Time zone & business date > Why a sale at 01:00 can belong to yesterday, and which date field to filter on. A restaurant’s day does not end at midnight. ScanFood therefore records two different dates on every bill, and picking the wrong one is the most common reason an export disagrees with the shop’s own report. ## Time zone [Section titled “Time zone”](#time-zone) Each shop has its own time zone, returned as an IANA name by `GET /v1/shops`: ```json { "shopId": "aK3mP9xQ2bR7cT4dV6wY", "timezone": "Asia/Bangkok" } ``` The value is `null` for a shop that never set one, which is the normal state for older shops. Treat `null` as `Asia/Bangkok` — that is what ScanFood itself does when it formats that shop’s dates. Do not pass the raw value to a zone parser without that fallback, or the first old branch you meet will break the run. Every timestamp is formatted for the reader who cares about it: | Field | Format | Zone | | ----------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------- | | `timestamp`, `updatedAt` on a transaction | ISO-8601 with an offset, e.g. `2026-09-01T13:30:00.000+07:00` | The shop’s zone | | `createdDate`, `coupons[].expireAt` on a member | ISO-8601 with an offset | The member brand’s zone | | `businessDate`, `billDate`, `lastVisit` | `YYYY-MM-DD` | A wall-clock date in that same zone — no time, no offset | | `serverTime` on every response | ISO-8601 ending in `Z` | UTC | | `from` / `to` in a request | `YYYYMMDD`, **no dashes** | Calendar days in the shop’s zone | | `updatedSince` in a request | ISO-8601 | Any zone you like — send `Z` and avoid the question | Requests have no dashes, responses do `from=20260901` is correct; `from=2026-09-01` is `400 bad_query`. Responses use `2026-09-01`. It reads like an inconsistency and it is the single most common first-day mistake, so convert explicitly instead of passing a date string through. Because the offset travels with the timestamp, you never have to know a shop’s zone to sort or compare bills — parse them as instants. You only need the zone when you want to say *“which day was that?”*, and for that the API has already done the work: read `billDate` or `businessDate` rather than re-deriving a day from the timestamp. ## Calendar date vs business date [Section titled “Calendar date vs business date”](#calendar-date-vs-business-date) | Field | Question it answers | | -------------- | -------------------------------------------------------------------------- | | `billDate` | On which calendar day was this bill paid, on the clock on the shop’s wall? | | `businessDate` | Which trading day’s takings does this bill belong to? | For a shop that closes before midnight the two are always identical. For a bar that stops serving at 03:00 they diverge every single night — and that is the whole point of having both. ## The shop cut-off [Section titled “The shop cut-off”](#the-shop-cut-off) Every shop sets a cut-off time: the moment its trading day rolls over. A shop with a 06:00 cut-off treats everything rung up between 06:00 today and 05:59 tomorrow as one trading day — which is exactly how its own end-of-day report, its cash count and its staff think about it. `businessDate` applies that rule for you. You do not need to know the cut-off, fetch it, or replicate the arithmetic. `businessDate` can be `null` A few bills cannot be assigned to a trading day with confidence: the bill has no end-of-day stamp on it **and** either its timestamp or the shop’s cut-off time cannot be read as a time. Age alone does not decide it — an old bill still gets a business date as long as the shop’s cut-off is readable. Those bills come back with `"businessDate": null` rather than a guess. Decide up front where such bills go in your reports; leaving them silently out of every bucket is how totals go missing. ## Choosing a filter [Section titled “Choosing a filter”](#choosing-a-filter) Two rules cover every case: 1. **Filter with `from`/`to`, which match `billDate`.** There is no way to filter by business date; the range parameters look at the calendar day only. 2. **Group by `businessDate` whenever the number has to agree with the shop.** Revenue, VAT, daily takings, commission — all of these are trading-day figures. That combination has one consequence worth designing around: to be sure you hold every bill of a trading day, you must fetch the calendar day **and** the following one, then group by `businessDate`. A bill rung up at 01:00 on 2 September carries `billDate: "2026-09-02"` while belonging to the trading day of 1 September. The safe pattern for a nightly export: * Fetch `from` = the day you are reporting, `to` = the day after. * Keep only the rows whose `businessDate` is the day you are reporting. * Re-run the day once more after the shop’s cut-off has passed, since late voids and re-issued tax invoices change bills after the fact — the incremental mode described in [Pagination & sync](/get-started/pagination-and-sync/) catches these automatically. ## Worked example [Section titled “Worked example”](#worked-example) A shop in `Asia/Bangkok` with a 06:00 cut-off. Two bills: | Paid at | `timestamp` | `billDate` | `businessDate` | | ------------ | ------------------------------- | ------------ | -------------- | | 1 Sep, 13:30 | `2026-09-01T13:30:00.000+07:00` | `2026-09-01` | `2026-09-01` | | 2 Sep, 01:40 | `2026-09-02T01:40:00.000+07:00` | `2026-09-02` | `2026-09-01` | The shop’s own report for 1 September contains both bills. So: * Requesting `from=20260901&to=20260901` returns **only the first** — the second has a September 2 calendar date. Your total is short. * Requesting `from=20260901&to=20260902` returns both, plus everything that genuinely belongs to 2 September. Filtering that result on `businessDate === "2026-09-01"` gives you exactly the shop’s number. If you compare against a shop’s daily report and land a few bills short, this is almost always why.