Skip to content

Members

A member is one person enrolled in a shop’s membership programme: their tier, their usable points and store credit, their coupons, and a small amount of behavioural summary (how many visits, when they last came). This is the resource a CRM, a loyalty engine or a customer-analytics warehouse reads.

Three endpoints:

All three are read-only.

Membership in ScanFood does not belong to a shop — it belongs to a membership base, identified by providerId. Several branches routinely share one base, so a customer who joined at one branch is recognised at every branch that shares it.

That is why this endpoint is scoped by providerId and not by shopId.

Key scope Membership bases you can read
shop the one providerId belonging to that shop
franchise every distinct providerId under the franchise

providerId is required on the list endpoint, and it must be one your key covers — otherwise you get 403 provider_not_in_scope. Get the list of values you are allowed to use from GET /v1/shops.

What is stored about a member is far more than what is returned: the underlying record holds their messaging account, phone, email, birthday, gender, photos, free-text notes, full visit history with per-visit amounts, and every points and credit movement. The API returns a fixed, hand-picked subset — see Privacy.

Members are identified by an opaque token that always begins with m1_:

m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM

The token is the only identifier this API exposes for a person. It replaces the internal record id, which is built from the customer’s messaging account and would leak their real identity if it were handed out.

What you can rely on:

Property Detail
Stable The same person always produces the same token. Safe as a primary key, a join key or a cursor.
Global The token does not depend on which key, shop or franchise you look from. Two keys reading the same person see the same token.
Not reversible You cannot recover the customer’s identity from the token.
Versioned The m1_ prefix is a version marker. Should the encoding ever be rotated, new tokens would begin m2_.

The memberId on a bill is exactly this token, so bill-to-member joins need no translation step:

// A bill from /v1/transactions
if (bill.memberId) {
const member = await getMember(bill.memberId); // /v1/members/{memberToken}
}

If you already hold a customer’s LINE user id — because they are talking to your bot, or opened your LIFF app — you can exchange it for that person’s member token without asking them for anything:

GET /ext/v1/members/lookup?providerId=pv3Yh9DkQ2sLmT6RwX1B&lineUserId=U4af4980629…

The answer is the token and nothing else:

{
"ok": true,
"data": {
"memberId": "m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM"
},
"serverTime": "2026-09-18T04:10:00.000Z"
}

It is the same token as everywhere else — so GET /v1/members/{memberToken} gives you tier, points and coupons, and every bill in GET /v1/transactions carrying that memberId is a purchase by the same person. This is the “a customer just messaged us — are they a member, and who are they to us?” case.

Note the direction of travel: you supply a LINE user id you already have, and get a token back. The API still does not hand a LINE user id out — hasLine remains the only thing a member response says about that channel.

Lookup is off by default and enabled per key, the same way tel and email are (see Privacy). A key without it gets 403 lookup_not_enabled on every call. Turning it on is a commercial and legal decision about lawful basis and consent under Thailand’s personal data protection law, not a technical toggle — ask your ScanFood contact, and expect to say what the lookup will be used for.

Parameter Required Notes
providerId yes The membership base to search. Must be covered by your key, else 403 provider_not_in_scope
lineUserId yes The customer’s LINE user id: U followed by 32 hexadecimal characters
Status error When it happens
400 bad_query providerId is empty, or lineUserId is not in the U + 32 hex form
403 provider_not_in_scope The membership base is outside what your key covers
403 lookup_not_enabled Your key does not have lookup switched on
404 not_found No member matched
Terminal window
curl -s -G https://api.scanfood.co/ext/v1/members/lookup \
-H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \
--data-urlencode "providerId=pv3Yh9DkQ2sLmT6RwX1B" \
--data-urlencode "lineUserId=U4af4980629304a1b8c2d3e4f5a6b7c8d"

If someone signed up more than once and holds several records in the same membership base, the lookup answers with the most recently created one.

Response envelope for the list endpoint:

{
"ok": true,
"data": [ { "…": "one object per member" } ],
"nextCursor": "m1_…",
"serverTime": "2026-09-10T01:45:00.000Z"
}

The single-member endpoint returns the same object under data with no nextCursor.

Field Type Nullable Meaning Example
memberId string yes The opaque member token. Same value as memberId on a bill. "m1_ZmQ3YjJkNGE1…"
rank string yes The tier code stored on the member record. null when none is recorded. "GOLD"
rankLevel number yes The member’s position in the base’s tier list, as a whole number counted from 0. Meaningful only where the base has automatic tier progression turned on. null when the member has no valid position recorded. 2
rankName string yes The tier name the member is on right now, resolved against the base’s current tier list. null if the member has no tier. Prefer this over rank for display. "โกลด์"
points number no Usable points, after expiry has been applied — not the lifetime total ever earned. 14097
credit number no Usable store credit, after expiry. 0 when the base does not run store credit at all. 200
creditEnabled boolean no Whether this membership base runs store credit. Read this before showing credit to anyone. true
coupons array no (may be []) Coupons in the member’s wallet, see Coupons. —
labels string[] no (may be []) Tags the shop attached to the member. ["vip", "แพ้ถั่ว"]
persona string[] no (may be []) Persona tags the shop attached to the member. ["สายหวาน"]
createdDate string yes When the member signed up. ISO-8601 with a +07:00 (Thailand) offset — membership bases carry no timezone of their own, so member times are always Thailand time. "2026-03-04T16:15:00.000+07:00"
visitCount integer no Number of recorded visits. The visit list itself is not returned. 57
lastVisit string yes Date of the most recent visit, YYYY-MM-DD. null if the member has never visited. "2026-09-07"
hasTel boolean no Whether a phone number exists on the record — not the number itself. true
hasEmail boolean no Whether an email address exists on the record. false
hasLine boolean no Whether a messaging account is linked. true

Two further fields appear only for keys explicitly granted personal-data access (see Privacy):

Field Type Nullable
tel string yes
email string yes

Fields deliberately absent: the member’s name and nickname, phone and email (unless granted), messaging account id, birthday, gender, photos, address, free-text notes, saved cards, the raw visit history, and the raw points and credit movement lists. There is also no loyalty segment (Loyal, At-Risk, New and so on) — those are computed in batch elsewhere and are not attached to the member record. Derive them yourself from visitCount, lastVisit and transaction history.

coupons[] is the member’s coupon wallet. Each entry:

Field Type Nullable Meaning
code string yes The short code the member shows at the counter. Unique per issued coupon.
status "available" | "used" yes "available" — still redeemable. "used" — already redeemed.
templateId string yes The coupon definition this one was issued from. Many coupons share one templateId.
name string yes The coupon name as the member sees it.
expireAt string yes Expiry, ISO-8601 with a +07:00 (Thailand) offset, like every other member time. null when the coupon never expires.

Two things worth planning for:

  • A coupon with status: "used" stays in the wallet. Filter on status before you show “coupons available”.
  • status describes redemption, not expiry. Treat expireAt as a separate condition and compare it against serverTime rather than the clock on your own machine.

Coupons are always read within the membership base you asked for, so a coupon belonging to another brand can never appear in the wallet.

There is no incremental mode for members. updatedSince is rejected with 400 not_supported.

So: page through the whole base with cursor.

Parameter Required Default Notes
providerId yes — Must be covered by your key, else 403 provider_not_in_scope
limit no 100 Above 200 is reduced to 200; an invalid value falls back to 100
cursor no — The nextCursor from the previous page — a m1_… token
updatedSince — — Not supported — 400 not_supported

Members come back ordered by sign-up date, oldest first, which makes a full pass stable: rows already returned do not jump ahead of you while you page. nextCursor is null on the last page.

Terminal window
# first page
curl -s -G https://api.scanfood.co/ext/v1/members \
-H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \
--data-urlencode "providerId=pv3Yh9DkQ2sLmT6RwX1B" \
--data-urlencode "limit=200"
# next page — pass the nextCursor from the response above
curl -s -G https://api.scanfood.co/ext/v1/members \
-H "Authorization: Bearer sf_live_4b8f2c1e9d07_3f9a2c1b7e4d8506a1b2c3d4e5f60718" \
--data-urlencode "providerId=pv3Yh9DkQ2sLmT6RwX1B" \
--data-urlencode "limit=200" \
--data-urlencode "cursor=m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM"

If a cursor points at a member who has since been removed, you get 400 bad_cursor. Restart that page without a cursor rather than treating it as a fatal error.

{
"ok": true,
"data": [
{
"memberId": "m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjM",
"rank": "GOLD",
"rankLevel": 2,
"rankName": "โกลด์",
"points": 14097,
"credit": 200,
"creditEnabled": true,
"coupons": [
{
"code": "ABC123",
"status": "available",
"templateId": "cp7Vb2NqZ5tMx8LwR3Kd",
"name": "ส่วนลด 50 บาท",
"expireAt": "2026-12-31T23:59:59.000+07:00"
},
{
"code": "XYZ789",
"status": "used",
"templateId": "cpQ4mL8vT2zNb6RwK9Ys",
"name": "ฟรีชาเย็น",
"expireAt": null
}
],
"labels": ["vip", "แพ้ถั่ว"],
"persona": ["สายหวาน"],
"createdDate": "2026-03-04T16:15:00.000+07:00",
"visitCount": 57,
"lastVisit": "2026-09-07",
"hasTel": true,
"hasEmail": false,
"hasLine": true
}
],
"nextCursor": null,
"serverTime": "2026-09-10T01:45:00.000Z"
}

Membership data is personal data, and this API is built so that a key cannot accidentally acquire more of it than it was given.

By default, no contact details are returned at all. You get hasTel, hasEmail and hasLine — enough to know whether a channel exists and to segment on it — but not the values.

Phone and email can be switched on per key, and only those two fields; there is no setting that opens anything else. A third, separate flag opens member lookup — and note what that flag does and does not do: it lets a key send in a LINE user id it already holds and receive a member token. It does not add the messaging account id to any response. No key level ever reads one out of ScanFood. Turning them on is a commercial and legal decision about lawful basis and consent under Thailand’s personal data protection law, not a technical toggle — ask your ScanFood contact, and expect to say what the data will be used for.

Everything else about the person stays inside ScanFood permanently:

Category Not available at any key level
Identity name, nickname, messaging account id, birthday, gender, photos, address
Free text staff notes on the member
Raw history full visit history (with bill ids and per-visit amounts), points movements, credit movements, promotion history
Stored instruments saved cards
Internal edit metadata, spending counters, tier-change bookkeeping