Stamp Identification Flow
Phone is the identity anchor (camera-less iPad POS); purchase stamps + a welcome stamp; phone verified via WhatsApp OTP through the Meta Cloud API directly.
Status: Accepted
Date: June 2026
Decision: Phone number is the primary identity anchor (POS collects it, no camera); stamps issue on paid orders. Registration also grants a one-time welcome stamp. Phone is verified via WhatsApp OTP (Meta Cloud API direct).
Supersedes: the “POS scans customer profile QR” assumption in Phase 3 — Stamps & Customers and the Data Model.
TL;DR
The POS runs on a camera-less iPad, so it can’t scan a profile QR. Instead, staff type the customer’s phone; it keys a store_customer per (shop_id, phone); stamps issue on paid orders. Registration grants a one-time welcome stamp and links the account by verified phone. Phone is verified via WhatsApp OTP.
Context
The original roadmap assumed the POS scans the customer’s profile QR with the iPad camera (pos.customers.lookupByQr), issuing a stamp once per paid order. Two realities changed this:
- Hardware: the POS runs on an iPad with no usable camera — it can’t scan anything.
- Auth: befday auth (Better Auth) ships with Google + Apple OAuth + email OTP, no phone OTP (
packages/auth/src/index.ts). This adds phone verification via the Better AuthphoneNumberplugin wired to WhatsApp.
So the decision combines phone as the durable identity anchor for purchase stamps (works on the camera-less iPad) with a welcome stamp on registration to drive downloads.
Decision
Phone number is the primary identity anchor. The POS collects it (no camera) and stamps issue on paid orders. Registering a befday account also grants a one-time welcome stamp. Phones are verified with WhatsApp OTP via the Meta Cloud API directly.
Flow
First-timer (purchase stamp):
1. Staff rings up the order on POS (pos)
2. Staff asks for the customer's phone number (skippable → anonymous sale)
3. Staff types phone → server finds-or-creates store_customer (shop_id, phone)
→ links it to the order
4. Customer pays → server issues exactly 1 stamp (idempotent via stamp_issued)
5. Stamp count updates on POS ("4/10")
Welcome stamp (app acquisition):
A. Staff offers: "Get the befday app for your stamps" → customer scans a static QR
B. QR → App Store / Play Store → install → register (Google / Apple)
C. During onboarding the app verifies the phone via WhatsApp OTP
D. First registration → 1 welcome stamp granted (idempotent per shop)
→ user_id links to the existing phone-keyed store_customer
→ previously earned purchase stamps carry over
- Identity anchor = verified phone, unique per
(shop_id, phone). Accounts link to it by matching the verified phone. - Two stamp sources: purchase stamps (1 per paid order, phone-identified) and a one-time welcome stamp (registration).
- The download QR is static — a plain “get the app” link, nothing to secure.
Anti-farming guard
- Purchase stamps: bound to a real paid order, idempotent via
orders.stamp_issued— one stamp per order. - Welcome stamp: idempotent — at most one per
(shop_id, store_customer), ever. Reinstall / re-register / re-link must not grant more.
Options Considered
A. Phone-number lookup at POS (chosen)
Staff types the customer’s phone; server find-or-creates a store_customer keyed by (shop_id, phone); stamp issues on payment; accounts later link by matching the verified phone.
- Pro: Zero-friction first stamp tied to a real purchase; works on the camera-less iPad today; durable across reinstalls (anchored to phone, not device).
- Pro: Deterministic account linking via unique
(shop_id, phone)— no fuzzy device merge. - Con: Staff hand-keys numbers (mis-keys, slower at peak).
- Con: Needs phone verification (closed here via WhatsApp OTP).
B. Static “download + register” QR → welcome stamp (adopted as a complement)
A static QR drives app install + registration; registration grants one welcome stamp. Used alongside A as the app-acquisition hook, not as the identity model.
C. POS displays a per-order claim QR; customer’s phone scans it (deferred)
POS shows a per-order, single-use, short-lived token QR; the customer scans it in native to claim that order’s stamp. Safe, but needs claim-token machinery and a scanner in native. Deferred — see Appendix.
D. POS displays a QR that is “worth a stamp” (rejected)
A QR that directly grants a stamp on scan. Rejected — it’s a bearer token: screenshot + share = unlimited free stamps.
App Responsibilities
| App | Responsibility | Change from original roadmap |
|---|---|---|
merchant |
Configure loyalty program (stamp goal, reward); generate/display the shop’s static “get the app” QR; view results | Adds QR generation; rest unchanged |
pos |
Phone-number entry + find/create customer; show stamp count after payment; show the static app QR. No camera | Was: scans customer QR. Now: phone entry (no camera) |
native |
Verify phone via WhatsApp OTP at onboarding; on first registration grant the welcome stamp + link user_id to the phone-keyed store_customer; show “My stamps” |
Adds OTP verify + welcome stamp; no scanner |
No camera is required on any device for the primary flow.
Data Model Impact
The verified phone is the identity anchor on store_customers; account linking happens by matching phone.
store_customers (revised from the Data Model)
| Column | Type | Notes |
|---|---|---|
id |
uuid | PK |
shop_id |
uuid | FK |
phone |
text | identity anchor — unique per (shop_id, phone) |
phone_verified |
boolean | default false; set true after WhatsApp OTP |
user_id |
text | nullable FK → user.id — set when a befday account links |
display_name |
text | nullable |
visit_count |
integer | denormalized counter |
created_at |
timestamp |
- Unique constraint:
(shop_id, phone)— drives find-or-create. qr_tokenfrom the original spec is not needed (POS can’t scan a profile QR).
Purchase-stamp issuance (at payment, customer already phone-linked)
Because the phone identifies the customer before payment, the stamp issues inside the normal orders.pay transaction from Phase 3:
BEGIN;
-- payment + order update (from Phase 1)
INSERT INTO stamp_events (stamp_card_id, order_id, issued_at);
UPDATE stamp_cards SET stamp_count = stamp_count + 1;
UPDATE store_customers SET visit_count = visit_count + 1;
UPDATE orders SET stamp_issued = true WHERE id = :order_id AND stamp_issued = false;
COMMIT;
Welcome-stamp issuance (on registration, idempotent)
Granted once when a registered user is first associated with a shop’s customer record. order_id is NULL because there is no purchase:
INSERT INTO stamp_events (stamp_card_id, order_id, issued_at)
VALUES (:stamp_card_id, NULL, now()) -- NULL order_id = welcome stamp, not a purchase
ON CONFLICT DO NOTHING; -- enforce one welcome stamp per card
stamp_events.order_idbecomes nullable — a welcome stamp has no order. This distinguishes “joined” from “purchased” in the audit log.
Account linking (on registration, after OTP)
When a befday user registers and verifies their phone via WhatsApp OTP, link them to the existing phone-keyed store_customer:
-- on verified phone match
UPDATE store_customers
SET user_id = :user_id
WHERE shop_id = :shop_id AND phone = :phone AND user_id IS NULL;
If a store_customer already exists for that user_id in the same shop, the two records must be merged (move stamp_cards, stamp_events, visit_count). Merge is deterministic because phone is unique per shop.
Phone verification — WhatsApp OTP via Meta Cloud API (direct)
Verification uses WhatsApp OTP through the Meta Cloud API directly (no BSP):
- A BSP (Kapso/Twilio/etc.) does not lower Meta’s per-message fee — the ~$0.0140/OTP passthrough is identical. BSPs only sell managed tokens/webhooks/logs, a separate platform fee not worth it for low-volume OTP.
- All-in cost: $0.0140/OTP (RM 0.066), platform fee $0, if the WABA is registered in Malaysia (~$0.0418/OTP if registered abroad).
- Requires: Meta Business account + verified WABA + registered number; one approved authentication template; Cloud API
/messagescalls (access token + rotation, delivery webhooks, rate-limit/error handling);sendOTPglue wiring Better Auth’sphoneNumberplugin to the WhatsApp auth template instead of SMS.
API Impact
| Procedure | Status | Notes |
|---|---|---|
pos.customers.lookupByPhone |
Keep | Primary path — find-or-create by (shop_id, phone) |
consumer.auth.sendPhoneOtp |
New | Sends WhatsApp OTP via Meta Cloud API (Better Auth phoneNumber) |
consumer.auth.verifyPhoneOtp |
New | Verifies the code; sets phone_verified = true |
consumer.customers.linkByPhone |
New | Links signed-in user_id to a verified phone-keyed store_customer (+ merge) |
consumer.stamps.claimWelcome |
New | Grants the one-time welcome stamp for a shop (idempotent) |
merchant.shops.appQr |
New | Returns the shop’s static “download the app” QR/deep link |
pos.customers.lookupByQr |
Dropped | POS can’t scan |
pos.orders.createClaimToken |
Deferred | Part of the per-order claim-QR path (Appendix) |
consumer.stamps.claimOrder |
Deferred | Same — part of the deferred claim-QR path |
Consequences
| Type | Consequence |
|---|---|
| Pro | Zero-friction first stamp tied to a real purchase; no login wall, no camera; works on the camera-less iPad today. |
| Pro | Durable loyalty — stamps tied to a verified phone survive reinstalls / new devices. |
| Pro | Phone is verified (WhatsApp OTP) — no unverified-claim risk; deterministic account linking via unique (shop_id, phone). |
| Pro | Welcome stamp makes stamping double as the app-acquisition funnel. |
| Con | Staff must hand-key phone numbers (mis-keys, slower at peak). Mitigated later by the optional claim-QR self-serve path. |
| Con | Adds WhatsApp OTP infrastructure (WABA, template, Cloud API glue) and a small per-OTP cost (~RM 0.066). |
Resolved Decisions
| Question | Decision |
|---|---|
| Identity model | Phone number is the anchor — find-or-create store_customer per (shop_id, phone) |
| Purchase stamp | 1 stamp per paid order, phone-identified, idempotent via orders.stamp_issued |
| Welcome stamp | One-time welcome stamp on registration (idempotent per shop), order_id NULL |
| Account linking | befday account with same verified phone → link to existing record; stamps carry over |
| Phone verification | WhatsApp OTP via Meta Cloud API direct (~$0.0140/OTP); Better Auth phoneNumber plugin |
| Per-order claim QR | Deferred (Appendix) |
Open Questions
- Welcome-stamp scope: one welcome stamp per shop, or one across all befday shops? (Doc assumes per shop.)
- Skipped phone at POS: anonymous sale earns no stamp — is that acceptable, or should the receipt offer a later “claim with phone” path?
Appendix (deferred) — Per-Order Claim QR (Option C)
Kept for the record; not in the first build. If staff-keyed phone numbers prove to be a friction point, add a self-serve path: the POS displays a per-order claim QR and the customer scans it with native.
- The QR encodes a per-order, server-generated, short-lived, single-use token — never “a free stamp.”
- Stamp issuance stays server-side, bound to a real paid order, idempotent via
orders.stamp_issued. - Screenshot + share is bounded: the first scan consumes the token. Worst case is one order’s stamp misdirected, never farming.
This would add an order_claim_tokens table (order_id, unique token, expires_at, consumed_at, consumed_by), a pos.orders.createClaimToken mutation, a consumer.stamps.claimOrder mutation, and a scanner (expo-camera) in native. Suggested TTL ~5 minutes (calm walk-away window), surfaced on the digital receipt.