Transactions
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— a page of bills for one shopGET /v1/transactions/{id}— 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”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.
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 |
| Channel P&L (dine-in vs delivery) | channel.code + amounts |
Bill fields
Section titled “Bill fields”Response envelope for the list endpoint:
{ "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. | — |
amounts |
object | no | Bill totals, see Amounts and tax. | — |
payments |
array | no (may be []) |
How the customer paid, see 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.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.
Filtering
Section titled “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.
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”This distinction is the single most common source of “the numbers don’t match”.
businessDateis 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.billDateis the ordinary calendar day. For a shop that closes its day at 06:00, a bill paid at 02:00 has tomorrow’sbillDatebut yesterday’sbusinessDate.
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:
- Request
from= the day before,to= the day after the trading day you want (three calendar days). - Group the returned rows by
businessDate. - Keep only the group you asked for.
When updatedAt is stamped
Section titled “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.
Voided bills come back too
Section titled “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.
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 |
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”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[] |
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[]”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”| 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 |
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[] 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 |
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”ScanFood has no separate refund object. A refund at the counter is performed by voiding the bill. That means:
statushas exactly two values:"completed"and"void".- There is no partial-refund concept and no negative bill.
voidReasoncarries whatever the staff member typed.- Voiding stamps
updatedAt, so the change reaches an incremental sync.
Your importer should therefore:
- Upsert on
id— never insert blindly. - Recompute period totals excluding
status: "void"rather than assuming a bill you imported yesterday is still valid. - Keep voided rows rather than deleting them, so that a restated day can be explained.
Keeping in sync
Section titled “Keeping in sync”Daily reconciliation
Section titled “Daily reconciliation”Pull one trading day and group by businessDate.
# 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"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');import osimport 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”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 -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"/** * 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);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)One bill by id
Section titled “One bill by id”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”{ "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”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”- Members — resolve
memberIdon a bill to a member record. - Pagination and sync — cursor rules in detail.
- Time zones and business date.
- Errors — every code this endpoint can return.
- Rate limits — how fast you can pull.