The QR can’t lie if the browser never holds the number.
The deciding line: the enrolment link is built server-side; the app receives a URL string and never a phone number.
To enrol a WhatsApp number, counsel open a chat with the firm’s
business account and send a single-use code. The app used to build that
https://wa.me/… link in the browser from a
client-side env var. Story-3067 renders the same link into a
QR code. A wrong number then stops being a wrong line of
text a human might notice and becomes a scannable wrong link: the
phone silently opens a chat with the wrong business account and enrolment
dead-ends with nothing to diagnose. Story-3062 moves the number onto the
server so the browser can never render a scannable wrong one — it is
never in the browser.
The trap: two numeric “WhatsApp numbers”
This repo already resolves a numeric WhatsApp value — Meta’s
phone_number_id, held as the
/pramaan/stg/whatsapp-phone-number-id SecureString and used
only to address the Graph send endpoint
({base}/{phone_number_id}/messages). It is not
the dialable number. Because it is numeric, dropping it into
https://wa.me/{…} produces a link that looks completely
plausible and is completely wrong — exactly the failure this ticket
exists to prevent. The deep link is built from a separate dialable
business number, never from phone_number_id.
| Value | What it is | Where it lives | Used for the deep link? |
|---|---|---|---|
phone_number_id |
Meta Graph API send-endpoint identifier (a secret) | /pramaan/stg/whatsapp-phone-number-id SecureString |
Never — numeric but not dialable |
whatsapp_business_number |
Dialable E.164 business number (public) | WHATSAPP_BUSINESS_NUMBER settings/env |
Yes — the wa.me / QR target |
What the contract gains
EnrollmentCodeView gains two fields, regenerated into
openapi/app-api.json:
deepLink—https://wa.me/{digits}?text={url-encoded code}, built from the server-resolved number and the freshly issued code. Story-3067 renders this into the QR and receives only this string.displayNumber— the human-facing+{digits}form for the desktop-fallback line when the QR cannot be scanned.
The number is resolved in src/shared/whatsapp/deep_link.py,
validated as 8–15 E.164 digits. If WHATSAPP_BUSINESS_NUMBER
is unset or implausible, issuing a code fails loudly with
WhatsAppBusinessNumberUnavailableError before any database
write — no half-minted code, and never a guessed or partial
link. The error carries a remediation and only the digit count, never the
configured value.
What the tests prove
- The
deepLinkon an issued view carries the freshly issued code, url-encoded, and itswa.mepath segment is digits only — no+, spaces, or thephone_number_idshape. displayNumberis present and non-empty.- Resolution strips a leading
+and separators; 8- and 15-digit numbers are accepted at the edges. - An unset, empty, non-digit, too-short (<8), or too-long (>15)
value raises
WhatsAppBusinessNumberUnavailableErrorinstead of emitting a partial link, and the value never leaks into the error. - Planted-negative: prefixing the
wa.mesegment with+turns the digits-only tests red — the guard was observed failing before it was trusted.
Operational boundary
This is an application contract and config change only. It adds no cloud
capability, IAM grant, tracing span, deploy workflow, or model call. The
dialable number is public (it goes into a QR the app shows), so it
is plain settings/env — deliberately not an SSM
SecureString like the credentials. The channel-ingress consume
path returns an EnrollmentResult, not this view, so the
ingress worker never needs the business number. The paired pramaan-app
consumer (Story-3067, which deletes
NEXT_PUBLIC_WHATSAPP_BUSINESS_NUMBER,
plausibleWhatsAppNumber, and the ponytail:
comment) rides the repo’s routine codegen-sync chore and is out of
this ticket’s scope.