Inbox Customer Capture
A merchant saving a phone number on an inbox conversation auto-creates a phone-keyed store_customer; no phone, no customer. Phone is the only anchor, which gives free dedup against POS and app.
Status: Accepted
Date: June 2026
Decision: In the team inbox, when a merchant saves a phone number on a conversation’s contact panel, that action auto-creates (find-or-creates) a phone-keyed store_customer for the shop, who then appears in /customers. No phone, no customer — a contact with no saved phone stays an inbox-only record and never enters the customer list.
Builds on: Stamp Identification Flow (the (shop_id, phone) anchor) and Customers.
TL;DR
The inbox already holds contacts (WhatsApp / Instagram / TikTok / Threads / Messenger / Telegram), but they are not customers — they have no link to a phone-keyed store_customer. This idea adds one explicit bridge: the merchant saves a phone number on the conversation, and that save auto-creates the customer, keyed by (shop_id, phone). The act of saving the phone is the create. There is no separate “create customer” button, and no creation on inbound message.
Context
Two facts drive this:
- Inbox contacts are channel-keyed, not identity-keyed. An
InboxConversation(apps/merchant/src/lib/inbox-mock.ts) carries aplatformUserId(the phone for WhatsApp, an opaque platform id otherwise) and aContactDetailsblob, but nouserIdand nostore_customer_id. So someone who only ever messages the shop does not appear in/customers. store_customeris already the agreed anchor. The Stamp Identification Flow decision keys a customer per(shop_id, phone). The inbox should feed that same anchor — it is just another producer ofstore_customers, alongside POS phone entry and app account-linking. We do not invent a parallel inbox-only identity model.
The gap: nothing turns an inbox contact into a customer. This closes it with the smallest, most deliberate trigger.
Decision
Saving a phone number on an inbox conversation auto-creates the customer.
The rule
- Phone saved in the inbox → auto-create. Find-or-create
store_customer (shop_id, phone), link the conversation to it, and the contact now shows in/customers. - No phone → no customer. The conversation stays an inbox-only contact (channel handle only). It never enters
/customers. - Phone is the only anchor. No channel-handle anchor, no fuzzy matching, no identity side-table. One key:
(shop_id, phone).
“Auto-create” — be precise
| Trigger | Auto-create? |
|---|---|
| Inbound message arrives | ❌ No — would create junk records from spam / one-off questions |
| Merchant saves a phone on the panel | ✅ Yes — the deliberate trigger; creation is the automatic consequence |
The merchant’s save is the consent / creation gate. Creation is the automatic result of that save, not of message traffic.
Flow
Merchant opens a conversation (any channel)
→ Contact panel shows the person + channel handle(s)
→ Merchant enters / confirms a phone number → Save
→ server find-or-creates store_customer (shop_id, phone)
- phone_verified = false (inbox-sourced, not OTP-verified)
- internal_note (your label), email, socials, tags copied from the conversation
→ conversation.store_customer_id is set (thread ↔ customer linked)
→ Contact now appears in /customers; panel shows "Saved as customer"
No phone entered → nothing created; conversation remains inbox-only.
Editing the phone
The phone field is editable, not save-once. What happens on edit is decided entirely by the unique (shop_id, phone) key plus whether the current record has history:
| Case | Condition | Behavior |
|---|---|---|
| Add | Was blank → a phone is entered | Find-or-create store_customer (shop_id, phone), link the conversation. |
| Correct a typo | New number is free and current record has no other signals (no orders/stamps/redemptions) | Update store_customer.phone in place; conversation link unchanged. |
| Re-point (current has history) | New number is free but current record has history | Don’t mutate the historical record — find-or-create a new store_customer for the new number, re-link the conversation, leave the old customer intact. |
| Re-point (number taken) | New number already belongs to another store_customer in this shop |
Re-link the conversation to that existing customer. Never auto-merge two histories. |
The principle: a typo-fix on a record with nothing to lose is corrected in place; anything that would rewrite a customer with history instead re-links the thread to a different (new or existing) customer. Phone-key uniqueness means none of this needs fuzzy matching.
Why phone-only (the deliberate trade-off)
Requiring a phone is a feature, not a limitation:
- Free, exact dedup.
(shop_id, phone)is unique, so an inbox save, a POS phone entry, and an app account-link all land on the same row. Merge is automatic — no manual merge UI, no fuzzy name matching in v1. - Clean customer list. Every
/customersrow has a real, reachable number. No junk, no half-records. - One identity model. The inbox reuses the stamp-flow anchor exactly; nothing new to reconcile.
What we consciously give up: a regular who only ever DMs on Instagram / TikTok / Telegram / Messenger and never shares a phone stays inbox-only and won’t appear in /customers. That is an accepted v1 line. If it ever matters, a channel-handle anchor can be added later without breaking the phone path.
Per channel, what “save a phone” looks like:
| Channel | Phone available on contact? | Save path |
|---|---|---|
✅ yes — platformUserId is the phone |
One tap — the number is already there | |
| Messenger | ❌ page-scoped id only | Merchant types the phone the customer gave |
| ❌ IG-scoped id only | Merchant types the phone the customer gave | |
| TikTok | ❌ TikTok id only | Merchant types the phone the customer gave |
| Telegram | ❌ phone hidden by default | Merchant types the phone the customer gave |
WhatsApp is the zero-friction case; everywhere else the merchant supplies the number, and that typed number is what creates the customer.
Name ownership: the name is identity, the label is yours
The displayed name is always the person’s identity and is read-only to the merchant — they can never overwrite who someone is. What the merchant can edit is their own label (store_customer.internal_note): a shop-private nickname (“Aisy Rhmn”, “Priya — group bookings”) that lives only in their shop.
The key property: the label survives an app-link. A walk-in saved today has no app account, so their name falls back to the phone number while the label carries the merchant’s shorthand. If that same person later registers in apps/mobile, the phone match attaches user_id, name becomes their real account name (user.name) — and the label stays exactly where it was, beside the real name. The merchant never loses the reference they were using to recognize that person.
| Stage | name (read-only) |
label (internal_note, editable) |
|---|---|---|
| Captured, no app yet | phone number fallback | “Aisy Rhmn — oat milk regular” |
| Same person app-links | “Aishah Rahman” (real) | “Aisy Rhmn — oat milk regular” (unchanged) |
So there is no name collision and nothing to reconcile: the merchant’s label and the consumer’s real name are two separate fields that coexist, never one overwriting the other.
In the merchant UI, the label is editable in two places, both writing the same store_customer.internal_note:
- Inbox contact panel (
apps/merchant/src/components/inbox/contact-panel.tsx):MerchantLabelFieldsits at the top of the panel under the avatar. It shows the label as the prominent line with the channel name as a muted subtitle, or an “Add your label” prompt when empty — so a teammate can name the person the moment they open the thread, even before saving a phone. - Customers dashboard (
apps/merchant/src/components/customers-dashboard.tsx):CustomerNameFieldis always read-only — it showsname(real name or phone fallback) plus a verified badge forappcustomers.MerchantLabelFieldrenders for every customer (capturedandappalike) and is the only editable identity field.- The table row leads with the label when present and shows the real name as a subtitle, so both are always visible at a glance.
Privacy & isolation (per-shop)
The CRM is strictly shop-scoped. Everything a merchant types about a person is owned by their shop_id and is never exposed to another merchant:
- Your label (
internal_note), contact panel (phone/email/socials), and tags all live on that shop’sstore_customer/ conversation rows. Another shop has no row to read them from. - The same phone at two shops is two separate records —
(shopA, phone)vs(shopB, phone). There is no cross-shop merge or shared view; histories are never auto-merged (see the re-point table above). - The only value that looks “shared” is the consumer’s real
user.name— but that is owned by the consumer (set inapps/mobile), shown read-only, and identical across shops only because the user chose it, not because one merchant can see another’s edits.
Enforcement is the (shop_id, …) key on every read/write. Server queries must always scope by the authenticated shop; never query a store_customer by phone alone.
Not a login, not verified
- Not auth. This never creates a
useraccount and never touches the auth flow (OAuth-only; see POS Identification). Astore_customeris a shop-scoped CRM record, not a credential. phone_verified = false. An inbox-saved phone is real and reachable, but it is not OTP-verified. It is fine for a CRM contact; it must not auto-issue stamps / rewards. Only an app WhatsApp-OTP (stamp flow) or app account-link flipsphone_verifiedtrue and unlocks value issuance (anti-farming).
Data model impact
Reuses store_customers from the Stamp Identification Flow. The only net-new field is a link from the conversation to the customer.
// inbox conversations (whatsapp/social) — add the link
// (today the inbox is mock-only: apps/merchant/src/lib/inbox-mock.ts)
conversations: {
id,
shopId,
channel, // whatsapp | instagram | tiktok | threads | messenger | telegram
channelContactId, // = platformUserId: phone for WhatsApp, PSID / IG id otherwise
storeCustomerId, // nullable FK → store_customers.id ← set on phone-save
// ...
}
-- on merchant phone-save, inside one transaction:
-- 1) find-or-create the customer (unique on shop_id + phone). The merchant's
-- typed-in label goes to internal_note — NOT to any identity/name column —
-- so it never competes with the consumer's real user.name once they link.
INSERT INTO store_customers (shop_id, phone, phone_verified, internal_note)
VALUES (:shop_id, :phone, false, :label)
ON CONFLICT (shop_id, phone) DO UPDATE
SET internal_note = COALESCE(store_customers.internal_note, EXCLUDED.internal_note)
RETURNING id;
-- 2) link the conversation to it
UPDATE conversations SET store_customer_id = :store_customer_id WHERE id = :conversation_id;
Because (shop_id, phone) is unique, the ON CONFLICT path is exactly the dedup: if POS or the app already created that customer, the inbox save links to the existing row instead of duplicating.
UI impact (contact-panel.tsx)
Today the contact panel renders channel rows as read-only (apps/merchant/src/components/inbox/contact-panel.tsx). To support this:
- Make the phone an editable field with a Save action (other channel rows stay read-only).
- On save → call the find-or-create mutation above; show inline validation for a malformed number.
- After save, show a “Saved as customer · View in Customers” state linking to the
/customersdetail. - If no phone is saved, the panel keeps showing the channel handle only and the contact stays inbox-only.
Build order
- Schema —
conversations.store_customer_id(nullable FK), reusingstore_customersfrom idea 01. - Mutation —
inbox.contacts.savePhone→ find-or-createstore_customeron(shop_id, phone),phone_verified = false, copy name/email/socials/tags, link the conversation. - Panel UI — editable phone + Save; “Saved as customer” state.
- Customers surfacing — inbox-sourced customers appear in
/customerswith an “unverified phone” marker; excluded from reward-eligible flows until verified. - Verification bridge — when the same phone later verifies via app WhatsApp-OTP / account-link, flip
phone_verifiedand (if needed) merge — automatic, because the key already matches.
Open questions
- Unverified value: confirm the exact list of actions blocked while
phone_verified = false(stamps and reward redemption blocked; tags / notes / messaging allowed).