Self-healing WhatsApp business number from observed webhook metadata
Story-3062 moved the dialable WhatsApp business number out of the browser
into server settings. It was still a hand-copied mirror of Meta:
the value is typed in by a human, and a well-formed but wrong number
passes validation and produces a scannable, wrong QR — the root cause of
Bug-3080. This story makes the number self-healing by
capturing the real dialable number Meta already sends us in the
metadata of every inbound webhook, and preferring it over the
configured seed.
The data is already arriving
Every wax-seal-verified inbound webhook carries both the dialable number and its internal Graph id. The ingress seam read only the id and dropped the number:
"metadata": {
"display_phone_number": "15559998888", // the dialable number — was discarded
"phone_number_id": "111222333444555" // the stable Graph id
}
No Graph API call, no new credential, no new network path: the authoritative value already flows through traffic we process anyway.
What Changed
ops.whatsapp_business_numbers (migration 0226 — NEW)
phone_number_id PK the stable Graph id the number lives under
display_number observed dialable number, 8-15 E.164 digits (DB CHECK)
observed_at first-seen / last-changed timestamp
platform-global: no RLS, no firm_id (the ops.channel_consent_versions precedent)
GRANT SELECT, INSERT, UPDATE to pramaan_app; DELETE revoked
Settings.whatsapp_phone_number_id (NEW plain setting)
the capture GATE — a public Graph resource id, not a credential
src/domains/channel/ingress.py
extract_observed_business_numbers() deduped (id, number) pairs, once per webhook
capture_observed_business_number() gated idempotent upsert + drift logs
src/shared/whatsapp/deep_link.py
resolve_business_number(session) now async: prefers the observed number,
falls back to the settings seed
src/whatsapp_webhook/handler.py captures inside the ingress transaction
The constraint that makes this safe, not dangerous
An observed number is trusted only when its
phone_number_id matches the id we send enrolment from.
Without that gate, any number that receives wax-seal-verified traffic
could silently repoint enrolment at itself — self-healing would become
self-harming.
The acceptance ticket first specified matching against
resolve_whatsapp_credentials().phone_number_id — an SSM
read of whatsapp-phone-number-id. But the webhook Lambda
role is granted only meta-app-secret and
whatsapp-webhook-verify-token; reading a third param there
is a new IAM capability the ticket forbids, and exactly the
R2/R11 outage pattern (a dependency with no grant, green in CI, dead in
staging). The phone_number_id is not a secret — it
arrives in plaintext in every webhook and is hard-coded in the deploy
canary — so it becomes a plain setting
(WHATSAPP_PHONE_NUMBER_ID), the same reasoning that makes
the public whatsapp_business_number a plain setting. No SSM,
no IAM, single repo.
Fail closed
When WHATSAPP_PHONE_NUMBER_ID is unset the gate is unarmed and
captures nothing — it must never degrade to "trust every
number that messages us". The resolver then simply falls back to the
settings seed, which is also the cold-start path (a fresh deploy has
observed nothing yet). The number is demoted from source-of-truth to
bootstrap fallback, not deleted. Chore-3105 wires the value alongside
WHATSAPP_BUSINESS_NUMBER so it is not a second unset-config
surprise.
The drift detector — half the value of the ticket
Capture logs two signals that would have caught Bug-3080 before a user hit a dead link, both on inbound traffic (before any enrolment request):
phone_number_id_mismatch a verified webhook for a number that is NOT ours —
the configured id is wrong, or a second number was
provisioned. Nothing is persisted.
drift_from_configured the observed number differs from the configured seed —
the number changed in Meta. The link now self-heals;
the log tells ops to update the seed.
Cheap on the hot path
Capture is one idempotent upsert per webhook (observed pairs are deduped),
never a read-modify-write. The ON CONFLICT ... WHERE display_number
IS DISTINCT FROM EXCLUDED.display_number clause makes an unchanged
re-observation a true no-op — no write amplification and no log for the
steady state; only a first observation or a real change touches the row.
What Stays Out
The sender's SSM credential resolution
(src/shared/whatsapp/credentials.py) is untouched — this story
adds a second, independent source of a public identifier for the capture
gate; it does not migrate the sender off SSM. No IAM, no infra, no
SSM-write. Wiring WHATSAPP_PHONE_NUMBER_ID into deployed
environments is Chore-3105.