Data model & IDs
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”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/shopsis always the starting point. It returnsshopId,franchiseIdandproviderIdfor every branch your key can read, and no other call needs those relationships explained.franchiseIdandproviderIdare 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,deviceIdandstationIdwith 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”| 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 producem2_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.
Money and amounts
Section titled “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.netis what the customer paid, tips included. Subtractamounts.tipbefore you call it shop revenue: a tip passes through to staff.nullis not0.nullmeans “this does not apply here” — the feature is switched off at the shop, or the field was never written.0means the feature is on and the value happens to be zero. Do not conflate them when you aggregate.amounts.vatTypesays 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.discountis what the cashier keyed in by hand,amounts.campaignandamounts.couponlist the promotions and coupons that applied, andamounts.discountTotalis 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”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 |
Fields you should not depend on
Section titled “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,hasEmailandhasLinebooleans; 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:
messagetext in errors. Branch on theerrorcode instead.- The number of keys in an object. Fields get added within
/v1/; ignore what you do not recognise. channel.name,tableNameandcashier. 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; userankNamefor display.businessDatecan benullon bills that were not closed through the current POS path — see Time zone & business date.