Rate limits
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”| 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
400loop 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”| 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 |
Check your own usage
Section titled “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 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 -s -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/usage"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`);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"){ "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.countincludes the call you just made. A request is counted as it arrives, sotoday.remainingis exactly theX-RateLimit-Remainingheader on that same response — and asking for your usage spends one request of the quota like any other call.daysalways holds seven entries. A day with no traffic comes back as0rather than being left out, so a chart or a report needs no gap filling.- This is the one place that reports
perMinwithout tripping it. No header carries the per-minute ceiling; otherwise you only learn it from thelimitfield of a429 rate_limitedbody.
Handling 429
Section titled “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 |
{ "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:
# --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"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();}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”The limits are generous for a sync, and tight for a crawl. Some arithmetic before you write the scheduler:
- Ask for full pages.
limit=200is 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 seenextCursor: null. - One shop per request. A franchise of 20 branches polled every 15 minutes
is
20 × 4 × 24 = 1,920requests a day before paging — comfortable inside 10,000, but worth doing the sum for larger groups. - Poll incrementally.
updatedSincereturns 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.