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:
GET /v1/members— a page of members for one membership baseGET /v1/members/{memberToken}— one member by tokenGET /v1/members/lookup— the member token behind a LINE user id you already hold
All three are read-only.
What a member is
Section titled “What a member is”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.
Member tokens
Section titled “Member tokens”Members are identified by an opaque token that always begins with m1_:
m1_ZmQ3YjJkNGE1ZTZmNzg5MGExYjJjM2Q0ZTVmNjc4OTBhYmNkZWYxMjMThe 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/transactionsif (bill.memberId) { const member = await getMember(bill.memberId); // /v1/members/{memberToken}}Looking a member up from a LINE user id
Section titled “Looking a member up from a LINE user id”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 /v1/members/lookup— one member token, from a LINE user id
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.
Switched on per key
Section titled “Switched on per key”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.
Parameters
Section titled “Parameters”| 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 |
Errors
Section titled “Errors”| 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 |
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"const BASE = 'https://api.scanfood.co/ext/v1';const KEY = process.env.SCANFOOD_API_KEY;
/** LINE user id -> member token, or null when there is no match. */async function lookupMember(providerId, lineUserId) { const url = new URL(`${BASE}/members/lookup`); url.searchParams.set('providerId', providerId); url.searchParams.set('lineUserId', lineUserId);
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }); if (res.status === 404) return null; // not a member of this brand const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error}: ${body.message}`); return body.data.memberId;}
// On an inbound chat message — cache the result, do not call per message.const token = await lookupMember('pv3Yh9DkQ2sLmT6RwX1B', event.source.userId);if (token) { const member = await getMember(token); // /v1/members/{memberToken} console.log(member.rankName, member.points);}import osimport requests
BASE = "https://api.scanfood.co/ext/v1"HEADERS = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"}
def lookup_member(provider_id, line_user_id): """LINE user id -> member token, or None when there is no match.""" res = requests.get( f"{BASE}/members/lookup", headers=HEADERS, params={"providerId": provider_id, "lineUserId": line_user_id}, timeout=60, ) if res.status_code == 404: return None # not a member of this brand body = res.json() if res.status_code != 200: raise RuntimeError(f"{res.status_code} {body['error']}: {body['message']}") return body["data"]["memberId"]
token = lookup_member("pv3Yh9DkQ2sLmT6RwX1B", event.source.user_id)if token: member = get_member(token) # /v1/members/{memberToken} print(member["rankName"], member["points"])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.
Fields
Section titled “Fields”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
Section titled “Coupons”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 onstatusbefore you show “coupons available”. statusdescribes redemption, not expiry. TreatexpireAtas a separate condition and compare it againstserverTimerather 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.
Listing every member
Section titled “Listing every member”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.
# first pagecurl -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 abovecurl -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"const BASE = 'https://api.scanfood.co/ext/v1';const KEY = process.env.SCANFOOD_API_KEY;
async function getPage(path, params) { const url = new URL(`${BASE}${path}`); 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;}
/** Full pass over one membership base. */async function syncMembers(providerId, onMember) { let cursor = null; let seen = 0;
do { const page = await getPage('/members', { providerId, limit: 200, cursor }); for (const member of page.data) { onMember(member); // upsert on member.memberId seen += 1; } cursor = page.nextCursor; // Persist `cursor` here if you want to resume an interrupted run. } while (cursor);
return seen;}
const total = await syncMembers('pv3Yh9DkQ2sLmT6RwX1B', upsertMember);console.log('synced', total, 'members');
// One member, straight from a billasync function getMember(token) { const body = await getPage(`/members/${encodeURIComponent(token)}`, {}); return body.data;}import osimport requestsfrom urllib.parse import quote
BASE = "https://api.scanfood.co/ext/v1"HEADERS = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"}
def get_page(path, params=None): query = {k: v for k, v in (params or {}).items() if v is not None} res = requests.get(f"{BASE}{path}", 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 sync_members(provider_id, on_member): """Full pass over one membership base.""" cursor, seen = None, 0 while True: page = get_page("/members", { "providerId": provider_id, "limit": 200, "cursor": cursor, }) for member in page["data"]: on_member(member) # upsert on member["memberId"] seen += 1 cursor = page["nextCursor"] # Persist `cursor` here if you want to resume an interrupted run. if not cursor: return seen
total = sync_members("pv3Yh9DkQ2sLmT6RwX1B", upsert_member)print("synced", total, "members")
def get_member(token): """One member, straight from a bill.""" return get_page(f"/members/{quote(token, safe='')}")["data"]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.
Example response
Section titled “Example response”{ "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"}Privacy
Section titled “Privacy”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 |
Where to go next
Section titled “Where to go next”- Transactions —
memberIdon a bill points straight at a member. - Shops — where
providerIdcomes from. - Pagination and sync — cursor rules in detail.
- Errors — every code these endpoints can return.
- Security best practices.