Pairing the WhatsApp gateway and testing end to end
Date: 2026-08-14 · Updated: 2026-08-19, when readiness landed · Target: the x056 demo
Status: MERGED to main and live on the demo. Migrations 00194–00197 and 00200.
Readiness (4abcb0e9, 665fc61f) is on main but not yet deployed at the time of writing.
The short version, now that pairing is in the UI:
Admin → Notifications → WhatsApp → Pair gateway, scan the QR with a burner phone. The
modal closes itself when the scan lands. Everything below is the detail behind that, plus
the headless path for a host with no browser.
⚠️ Pair a burner SIM. wuzapi signs in as an ordinary linked device. Automated sending violates WhatsApp's terms and the account can be banned with no notice, no appeal and no support queue. Never pair a personal number or one anyone depends on. Production uses Meta's official Cloud API, which needs none of this.
Before you start
- A spare SIM in a phone you can hold while scanning.
- The demo host. The channel itself is merged and live; the readiness behaviour above is merged but needs a deploy before the demo shows it.
- 🔴 Check what is actually deployed first:
curl -s https://<demo>/api/v1/version. A co-agent's unmerged deploy has previously replaced a feature mid-test; if that returns something you do not recognise, sort that out before blaming WhatsApp.
1 — Deploy
Already merged and live on the demo. For a NEW deployment, add three settings to the deployment env file (deploy/*.env — not the compose file; a literal in compose beats --env-file, which has cost this project a production outage):
WHATSAPP_PROVIDER=wuzapi
WUZAPI_BASE_URL=http://wuzapi:8080
WUZAPI_ADMIN_TOKEN=<invent a long random string>
WUZAPI_ADMIN_TOKEN has no default on purpose — an admin token published in the repo would let anyone mint a sending session. Unset means the admin API is unusable, which is the safe way to fail.
⚠️ All three must appear in the APP service's environment: block in the compose file, not only the sidecar's. --env-file feeds compose INTERPOLATION; a value reaches a container only if that container names it. Declaring the admin token on the sidecar alone produced a Pair button that answered "WUZAPI_ADMIN_TOKEN is not set on the server" while the variable sat correctly in the env file. The app is what mints the session; the sidecar is what the token authenticates against.
Deploy the usual way. Do not hand-roll compose:
./deploy/update.sh --ref origin/main --yes
🔴 Use --ref. The demo host keeps its OWN checkout, which is not the tree you edit from a dev container even when the absolute paths match. Without --ref the deploy builds whatever that checkout happens to be sitting on and still reports "✓ complete" — two deploys in a row silently shipped the previous commit this way. --ref origin/main fetches and checks out first, so the stamp and the build agree.
⚠️ Migrations 00194–00197 run here. /readyz is whole-stack, so a dependency that is already down will roll back a good build — check the stack is healthy first.
2 — Start the gateway
It sits behind a compose profile, so it stays off unless asked for:
docker compose --profile wuzapi up -d wuzapi
docker compose logs --tail=20 wuzapi # expect "Server started. Waiting for connections..."
3 — Pair the SIM
In the admin UI (the normal way). Admin → Notifications → WhatsApp → Pair gateway. The QR appears in a modal; scan it with the phone holding the burner SIM. The screen polls the session every two seconds while the code is up, so the modal closes itself the moment the scan lands — there is nothing to confirm.
Re-pairing later does not need WUZAPI_ADMIN_TOKEN; only the very first mint does. That is deliberate, because the fortnightly re-pair has to work when nobody remembers an env var from two weeks ago.
Headless, for a host with no browser:
export WUZAPI_ADMIN_TOKEN=<the same value as in the env file>
scripts/wuzapi-pair.sh
It mints the API token, opens the session, fetches the QR and writes it to /tmp/wuzapi-qr.png, then waits.
On the phone holding the burner SIM: WhatsApp → Settings → Linked devices → Link a device, and scan that image.
The code rotates. If it expires before you scan, re-run the script. When the scan lands the script prints the token to paste into the admin screen and exits.
Check state at any time without changing anything:
scripts/wuzapi-pair.sh --status
connected: True, loggedIn: False means the sidecar is healthy but no account is linked — that is what an expired pairing looks like too.
4 — Configure the gateway in Obscura
Admin → Notifications → WhatsApp
Pairing from the UI fills the token in for you — there is nothing to paste. Check the Provider is the self-hosted gateway (only what the deployment offers is selectable) and press Save, which now sits at the foot of the settings form rather than in a tile of its own.
Message wording is editable here. Each message has a body field with named placeholders — {{requester}}, {{document}}, {{code}}, {{link}} — listed beside it. Leave a box empty to use the wording Obscura ships. An unfilled placeholder renders as nothing rather than putting {{document}} on somebody's phone.
⚠️ Those bodies apply to the self-hosted gateway only. On the Cloud API, Meta holds the approved wording and the body field is ignored; there you fill in the approved template name instead.
If the screen says "WhatsApp is switched off for this deployment",
WHATSAPP_PROVIDERdid not reach the app — check the env file, then that the app container restarted.If it says a token cannot be saved, the deployment has no at-rest master key. Saving is refused rather than storing a credential in the clear.
5 — Send the test
Same screen, Send a test message. Enter your own number (08… is fine — it is normalised).
This is the moment the whole chain is proven: settings → provider → sidecar → paired session → a phone.
6 — Prove a real notification
The test-send goes straight through the gateway. A notification has two more gates, and both are opt-in by design:
- Profile → Account — add your phone number. Without one, the channel skips you silently.
- Profile → Notifications — switch WhatsApp notifications on, and Save.
WhatsApp defaults to off while email defaults to on: an email address was given to us for this, a phone number was not.
Then trigger something that notifies you — assign yourself a workflow task is the quickest. You should get the in-app notification, the email, and now the WhatsApp message.
Also worth testing, since it is the message the whole design is aimed at: a signature request to an external signer with a phone number. That one names the colleague who asked — "Budi Santoso meminta tanda tangan Anda pada dokumen …" — which is what makes it recognisable to someone who has never used Obscura.
What happens on its own when the gateway is not paired
Since 4abcb0e9, an unpaired or unreachable gateway is treated as not deliverable rather than as configured, and the product routes around it instead of failing into silence:
- External signing codes go to email, and the signing page says email. Previously the page promised WhatsApp, the send failed, the code silently went to email, and the signer sat watching a phone nothing had been sent to.
- WhatsApp notifications are skipped, and the moment the gateway stops (or resumes) being deliverable is logged once — rather than one line per message for the fortnight nobody notices.
- A phone-only signing invitation is emailed instead, and the requester is told it happened — including, and especially, when the email fallback also failed, because then nobody has been contacted at all.
- The admin screen warns, naming the fix and stating the consequence.
It fails closed: an unreachable sidecar, an expired token or a probe that times out all count as not deliverable. A signer who gets their code by email when WhatsApp would also have worked is mildly inconvenienced; a signer promised WhatsApp who gets nothing cannot sign at all.
⚠️ This makes the channel degrade safely — it does not make it work. A gateway left unpaired sends no WhatsApp at all; everything above is a fallback, and re-pairing is still the fix.
When it does not work
Notification fan-out is best-effort and discards channel errors, so anything the readiness check does not cover still fails quietly — the diagnostics below matter more than usual.
| Symptom | Almost certainly | Fix |
|---|---|---|
Test-send fails, no session |
Nobody scanned the QR, or the pairing expired | Pair gateway in the admin UI |
| Signers get their codes by email when you expected WhatsApp | The gateway is not deliverable, so the channel was not offered | Admin → Notifications → WhatsApp; the banner names the reason |
| A requester was told their invitation "was emailed instead" | Same — a phone-only invite fell back | Re-pair, then resend if the signer must be reached on WhatsApp |
| Test-send works, notifications do not | Phone missing on the profile, or the toggle is off | §6 |
| Screen says WhatsApp is switched off | WHATSAPP_PROVIDER never reached the app |
env file, then restart |
| Nothing at all, no error anywhere | Provider is none — the no-op sends nothing and errors on nothing |
env file |
| Worked for a fortnight, then stopped | The linked device was signed out. A pairing dies when its phone stays offline ~14 days, and on reinstall or re-registration | re-pair; the admin screen warns, and messages fall back to email meanwhile |
| Message arrives with no link | APP_BASE_URL is unset or localhost — a relative link is dropped rather than sent broken |
fix APP_BASE_URL |
| Pair says the admin token is not set | It is on the sidecar's environment: but not the app's |
§1 |
| A deploy "succeeded" but the change is missing | The host's own checkout was on an older commit | update.sh --ref origin/main |
| A custom body sends without its link | The body has no {{link}} placeholder, or APP_BASE_URL is unset |
add the placeholder / fix the base URL |
The fortnight cliff is the one to remember. It is the reason the admin screen polls session status and warns, the reason the product now checks deliverability before promising WhatsApp to anybody, and the reason this provider is demo-only. Production runs on the Cloud API, where the credential is a config field and there is no session to lose.
What this does not prove
The self-hosted gateway and the Cloud API are not behaviourally identical. wuzapi takes free-form text; the Cloud API accepts only templates Meta approved in advance. A message that sends perfectly here can still be rejected by Meta's template review.
So this run proves the plumbing — settings, routing, preferences, phone resolution, rendering, the link. It does not prove the production path. Before a customer sees it, the template pack on the admin screen has to be submitted in their own Meta Business Manager and dry-run against the Cloud API.