Test 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 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.
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 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.
How to test safely against live data
Section titled “How to test safely against live data”Five habits, in the order they matter:
- 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. - 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. - Keep
limitsmall. The default is 100 and the maximum is 200. Uselimit=5while you are exploring — the response fits on a screen and the page loop is easier to reason about. - Read
X-RateLimit-Remainingon every response while you develop. It is the cheapest early warning you will get. - 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”This sequence spends four requests and shows you a real bill end to end.
# 0. keep the key out of your shell historyread -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 fullcurl -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-ratelimitconst 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}`));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”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.
When you are ready for production
Section titled “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 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/toin 31-day chunks, and only then start pollingupdatedSincefor 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.