Skip to content
befday
Esc
navigateopen⌘Jpreview
On this page

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:

  1. Inbox contacts are channel-keyed, not identity-keyed. An InboxConversation (apps/merchant/src/lib/inbox-mock.ts) carries a platformUserId (the phone for WhatsApp, an opaque platform id otherwise) and a ContactDetails blob, but no userId and no store_customer_id. So someone who only ever messages the shop does not appear in /customers.
  2. store_customer is 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 of store_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 /customers row 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
WhatsApp ✅ yes — platformUserId is the phone One tap — the number is already there
Messenger ❌ page-scoped id only Merchant types the phone the customer gave
Instagram ❌ 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): MerchantLabelField sits 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):
    • CustomerNameField is always read-only — it shows name (real name or phone fallback) plus a verified badge for app customers.
    • MerchantLabelField renders for every customer (captured and app alike) 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’s store_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 in apps/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 user account and never touches the auth flow (OAuth-only; see POS Identification). A store_customer is 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 flips phone_verified true 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:

  1. Make the phone an editable field with a Save action (other channel rows stay read-only).
  2. On save → call the find-or-create mutation above; show inline validation for a malformed number.
  3. After save, show a “Saved as customer · View in Customers” state linking to the /customers detail.
  4. If no phone is saved, the panel keeps showing the channel handle only and the contact stays inbox-only.

Build order

  1. Schemaconversations.store_customer_id (nullable FK), reusing store_customers from idea 01.
  2. Mutationinbox.contacts.savePhone → find-or-create store_customer on (shop_id, phone), phone_verified = false, copy name/email/socials/tags, link the conversation.
  3. Panel UI — editable phone + Save; “Saved as customer” state.
  4. Customers surfacing — inbox-sourced customers appear in /customers with an “unverified phone” marker; excluded from reward-eligible flows until verified.
  5. Verification bridge — when the same phone later verifies via app WhatsApp-OTP / account-link, flip phone_verified and (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).

Was this page helpful?