Skip to content

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.

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.
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.

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.

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

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.