Story-3062 · EPIC-3059

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:

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

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.