Skip to content

fix: identify WhatsApp senders by business-scoped user ID, not phone only (#519) - #533

Open
ArnasDon wants to merge 1 commit into
mainfrom
fix/519-whatsapp-bsuid
Open

fix: identify WhatsApp senders by business-scoped user ID, not phone only (#519)#533
ArnasDon wants to merge 1 commit into
mainfrom
fix/519-whatsapp-bsuid

Conversation

@ArnasDon

Copy link
Copy Markdown
Owner

Fixes #519.

The bug, confirmed against Meta's docs

Meta's business-scoped user IDs rollout changed the inbound payload. For a customer who has adopted a WhatsApp username and has no recent interaction history with the business, Meta omits the phone number entirely:

"contacts": [
  { "profile": { "name": "Sheena Nelson", "username": "realsheenanelson" },
    "user_id": "US.13491208655302741918",
    "parent_user_id": "US.ENT.11815799212886844830" }
],
"messages": [
  { "from_user_id": "US.13491208655302741918",
    "from_parent_user_id": "US.ENT.11815799212886844830",
    "id": "wamid.HBgL…", "type": "text", "text": { "body": "" } }
]

from and wa_id are both absent. The webhook keyed everything on the phone, and the failure chained:

  1. normalizePhone(undefined)''
  2. findExistingContact('') returns null by design
  3. migration 022's unique index is partialWHERE phone_normalized <> ''

So the insert always succeeded. One new contact and one new conversation per inbound message from the same person — the same fragmentation as #363, on a key nobody had guarded. And replies were impossible regardless: every send path required a valid E.164 number and threw without one.

Inbound

  • src/lib/whatsapp/wa-identity.ts (new, unit-tested) collapses a message plus its contacts[] entry into a single identity, handling both payload shapes. A BSUID field that isn't BSUID-shaped is discarded rather than stored — a junk value in wa_user_id would become a permanent wrong contact key.
  • Migration 040 adds wa_user_id / wa_parent_user_id / wa_username with a partial UNIQUE (account_id, wa_user_id) — the guarantee 022 gave phone numbers. phone stays NOT NULL and holds '' for a BSUID-only contact, which 022's partial index already tolerates and which keeps Contact.phone a plain string in the app.
  • findOrCreateContact resolves by BSUID first, phone second, then backfills whichever key it just learned. That backfill is the part that matters for the transition: a contact we've only ever known by phone gets its BSUID stamped the first time Meta sends one, so the next phone-less message resolves to the same row instead of forking. The reverse also holds — a BSUID-only contact gains its number the moment Meta discloses it.
  • A delivery carrying neither key is dropped with a log (still 200, so Meta doesn't retry) instead of creating an unmatchable row.

Outbound

Meta uses recipient for a BSUID and to + recipient_type for a phone number — sending both is a 400.

  • recipientFields() in meta-api.ts picks the right field for all six senders. The two are never ambiguous: a sanitized phone is digits only; a BSUID always carries a two-letter prefix and a dot. A test asserts a range of real phone formats never trip the BSUID branch.
  • resolveContactSendTarget() gives one shared answer to "how do we address this contact" for the inbox composer, the Flows engine (3 call sites), automations, and reactions. Those last two matter most in practice — an automation or flow auto-replying to a BSUID-only sender previously threw contact phone invalid.
  • Phone still wins whenever it's usable, since only that branch supports the trunk-prefix variant retry. A BSUID gets one attempt and never triggers the phone auto-correct write-back.

Also

A BSUID-only contact rendered a blank phone row in the inbox sidebar, the thread header, and contact detail. contactHandle() shows @username — or the BSUID — in that slot. No new i18n keys.

Verification

All four CI steps pass locally: lint (0 errors, 37 warnings — same as origin/main), typecheck, test (871 passing, 46 new), build.

The new tests reproduce the reported scenario end-to-end through the real POST handler: a username-only payload creates exactly one contact, a second message from the same BSUID reuses it, a transition payload backfills the BSUID onto a phone-matched contact, and the legacy phone-only path is asserted byte-for-byte unchanged.

One regression I introduced and then caught: routing the name through the display fallback meant an inbound message from a contact with no WhatsApp profile name would overwrite an agent's hand-edited name with the phone number. The backfill now only ever adopts a label Meta actually supplied, with a test pinning it.

The migration is structurally reviewed only — there is no local Postgres in this repo, so it has not been executed anywhere. .github/workflows/migrations.yml replays it from scratch in CI, and supabase/ci/verify-schema.sql now asserts the new index exists, so an IF NOT EXISTS typo can't apply cleanly and guarantee nothing.

Out of scope

Broadcasts do not support BSUID recipients. /api/whatsapp/broadcast, broadcast-core.ts and broadcast-resume.ts thread a phone-shaped contract through the public API and validate with isValidE164. A BSUID-only contact is skipped there exactly as a contact with no phone number always was — not a regression from this PR, but worth its own issue.

🤖 Generated with Claude Code

…only (#519)

Meta's username rollout stopped sending phone numbers. For a customer
who has adopted a WhatsApp username and has no recent interaction
history with the business, the message webhook omits `messages[].from`
AND `contacts[].wa_id` entirely — the sender is identified only by
`from_user_id` / `user_id`, a business-scoped user ID (BSUID).

The webhook keyed everything on the phone. `normalizePhone(undefined)`
returned '', `findExistingContact('')` returns null by design, and
migration 022's unique index is partial (`WHERE phone_normalized <> ''`)
— so nothing stopped a brand-new contact, and a brand-new conversation,
being inserted for EVERY inbound message from the same person. Replies
were impossible on top of that: every send path demanded a valid E.164
number and threw without one.

Inbound:
- `src/lib/whatsapp/wa-identity.ts` (new, unit-tested) collapses a
  message + its `contacts[]` entry into one identity, handling both the
  legacy and username-only payload shapes.
- Migration 040 adds `contacts.wa_user_id` / `wa_parent_user_id` /
  `wa_username` with a partial UNIQUE (account_id, wa_user_id) — the
  same guarantee 022 gave phone numbers. `phone` stays NOT NULL and
  holds '' for a BSUID-only contact, which 022's partial index already
  tolerates.
- `findOrCreateContact` resolves by BSUID first, phone second, and
  backfills whichever key it just learned onto the matched row. That
  backfill is what keeps history in one place across the transition: a
  contact we've only known by phone gets its BSUID stamped the first
  time Meta sends one, so the next phone-less message still finds it.
- A delivery carrying neither key is now dropped with a log instead of
  creating an unmatchable row.

Outbound — Meta uses `recipient` for a BSUID and `to` +
`recipient_type` for a phone number, never both:
- `recipientFields()` in `meta-api.ts` picks the right field for all six
  senders. The two can't be confused: a sanitized phone is digits only
  and a BSUID always carries a two-letter prefix and a dot.
- `resolveContactSendTarget()` gives the inbox composer, the Flows
  engine (3 sites), automations, and reactions one shared answer for
  "how do we address this contact". Phone still wins when usable, since
  only it supports the trunk-prefix variant retry; a BSUID gets a single
  attempt and never triggers the phone auto-correct write-back.

Also: a BSUID-only contact rendered a blank phone row in the inbox and
contact detail. `contactHandle()` shows `@username`, or the BSUID, in
that slot instead.

Broadcasts are NOT covered — `/api/whatsapp/broadcast`, `broadcast-core`
and `broadcast-resume` carry a `phone`-shaped contract through the
public API, and a BSUID-only contact is skipped there exactly as a
contact with no phone always was. Worth a follow-up issue.

The migration was structurally reviewed only — there is no local
Postgres in this repo — but `.github/workflows/migrations.yml` replays
it from scratch in CI, and `supabase/ci/verify-schema.sql` now asserts
the new index exists so an IF NOT EXISTS typo can't pass silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabase Bot commented Aug 27, 2026

Copy link
Copy Markdown

Updates to Preview Branch (fix/519-whatsapp-bsuid) ↗︎

Deployments Status Updated
Database 🔄 Thu, 27 Aug 2026 08:50:04 GMT
Services 🔄 Thu, 27 Aug 2026 08:50:04 GMT
APIs 🔄 Thu, 27 Aug 2026 08:50:04 GMT

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations 🔄 Thu, 27 Aug 2026 08:50:04 GMT
Migrations 🔄 Thu, 27 Aug 2026 08:50:04 GMT
Seeding 🔄 Thu, 27 Aug 2026 08:50:04 GMT
Edge Functions 🔄 Thu, 27 Aug 2026 08:50:04 GMT

Preview Branch Database Settings ↗︎.
Learn more about Supabase Branching ↗︎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] wa_username is a must now

1 participant