think
16px
820px

WhatsApp for notifications — research & options

Date: 2026-08-14 · Status: research only, nothing built · Asked: find libraries, preferably free ones; is tulir/whatsmeow the answer?
Scope decided (user, 2026-08-14): everything — staff notifications and external-party touchpoints (signer invites, share links); per-category calls made later.


✅ What shipped (updated 2026-08-17)

Merged to main, live on the x056 demo. Migrations 00194–00197 and 00200. The research
below stands as written; this is what came out of it and what is still open.

Built: internal/platform/whatsapp — one port, providers cloud | wuzapi | none
selected by WHATSAPP_PROVIDER; per-tenant encrypted settings; the admin panel with the
template pack; in-UI QR pairing (the shell script survives as the headless path); WhatsApp
as a notify channel with a per-user opt-in; e-sign's second WhatsApp client retired and its
table dropped; signer invitations that name the colleague who asked (created_by, mig 00197);
and administrator-editable message wording with named placeholders (mig 00200).

Two pre-existing bugs it uncovered and fixed: the notify fan-out gated preferences with
if ch.Name() == "email" and sent every other channel unconditionally, so the first channel
added after email would have ignored every user preference silently; and the e-sign WhatsApp
settings could store a Meta access token in PLAINTEXT when no master key was configured.

What the demo does NOT prove. wuzapi takes free-form text; the Cloud API takes only
templates Meta approved in advance. A message that sends perfectly on the demo can still be
rejected in review. The template pack still has to be submitted in a customer's own Business
Manager and dry-run against the Cloud API before a customer sees it.

Still open: counsel's read of UU PDP before prod (§9); Mekari as the e-sign PSrE (§13); and
sign_invite through the notify path still degrades to generic, because notify knows the
signer's name and not the requester's — the e-sign inviter, which does know, uses the real
template.

Things that cost a deploy each, worth remembering

  • 🔴 --env-file feeds compose INTERPOLATION only. A value reaches a container only if
    that container's environment: block names it.
  • 🔴 The demo host keeps its own git checkout, distinct from a dev container's tree even at
    the same absolute path. Deploy with update.sh --ref origin/main or you ship the old commit
    and still get "✓ complete".
  • ⚠️ wuzapi's API.md is wrong: the status fields are connected/loggedIn, auth is a bare
    Token header, and the image has curl but no wget.
  • ⚠️ The QR is not ready when /session/connect returns 200 — poll for it.
  • ⚠️ "Unpaired" must be a STATE, not an error, or the pairing UI hides behind a status call
    that needs the token pairing creates.

The answer: what's the correct tool

The Meta WhatsApp Business Cloud API, called directly — with no WhatsApp library at all.

The one-line reason: whatsmeow makes you a WhatsApp user. We need to be a WhatsApp business. It is a client library for operating a personal account; our job is to be an identified sender that a government agency's counterparty can trust. Those are different products, not two implementations of one thing.

Per layer, the right tool is:

Layer Correct tool Why
Transport net/http + the sender we already have (esign/adapters/phone_whatsapp.go), promoted to a shared platform/whatsapp Cloud API is HTTPS + JSON. Zero new dependencies. Token encryption, live settings reload, MSISDN normalisation and error masking already written and in production use.
Delivery semantics The existing notify/app.Channel port Already the right shape: best-effort fan-out after the in-app row commits.
Content Our own template registry, mirroring notify/domain/emailtpl.go, mapped onto Meta-approved utility templates This is the actual work. See §3.
Vendor Meta direct — no BSP. See §5: BSPs are ruled out on ownership grounds, not price. We must own the Business Manager. A BSP that owns it can hold the number hostage.
Shape A swappable WHATSAPP_PROVIDER port, like PSrE/e-Meterai — cloud | wuzapi | none (§8) One active provider, not two. No-op fallback when unconfigured, same as selectSealer.
Self-hosted option whatsmeow via wuzapi — a sidecar, never linked into our binary §6: whatsmeow's Signal dependency is GPL-3.0. Linking it into obscura-server would put Obscura's source under GPLv3 for every on-prem customer.

Widening scope to external parties did not soften the whatsmeow verdict — it disqualified it. Messaging people who never contacted you, from an unofficial client, is precisely the pattern Meta's automated enforcement bans for. And the blast radius changes: a silent failure no longer means a colleague misses a task, it means a customer's counterparty is never told to sign — the exact failure we already fixed once in SendEmailOnly.

Two blockers that only exist because scope is "everything"

  1. ~~🔴 Opt-in has nowhere to live for external parties.~~ CORRECTED 2026-08-14 — this was overstated, see §9. The earlier claim ("Meta requires demonstrable prior consent for every business-initiated message, no transactional exemption") was based on BSP compliance blogs and is out of date. Meta relaxed the rule in November 2024, and Indonesian law provides an independent lawful basis. Sending a signing invite to a signer whose number the requester supplied is permitted. The residual risk is not legal — it is block rate. §9 replaces this.

  2. ~~🔴 Sender identity is per-WABA, and that caps Cloud.~~ CLOSED 2026-08-14 — each customer brings their own WABA (§5). The ~20–25 numbers per Business Manager ceiling only bound us while we owned the numbers; it no longer applies. Per-tenant settings turn out to be free (the settings table is already in the tenant migration set). The cost moves to the customer: Meta verification and per-customer template approval. Details and consequences in §5.


TL;DR

  1. We already ship a WhatsApp sender. go/internal/esign/adapters/phone_whatsapp.go talks to the Meta WhatsApp Business Cloud API, with admin-managed settings, an encrypted token, an admin UI and a "send test" button. It is wired to e-sign OTP only. Notifications never touch it.
  2. So the real question is not "which library". The Cloud API is plain HTTPS + JSON — no library, no dependency. whatsmeow is only relevant if we deliberately choose the unofficial route.
  3. whatsmeow is good code but the wrong tool for production notifications here. It is an unofficial reverse-engineered client (ban risk, ToS), it needs a real phone number paired by QR, and a linked device is force-logged-out if the primary phone stays offline ~14 days. On a server that is an outage waiting to happen.
  4. The money it saves is trivial at our scale. Prod has ~11 users. Realistic notification volume costs roughly Rp 60k–600k/month. That is what whatsmeow would save, in exchange for a channel that can be killed by Meta without appeal.
  5. Recommendation: extend the existing Cloud API adapter into a notify.Channel (Route A). Keep the Channel port clean enough that a whatsmeow adapter could be dropped in later for a self-hosted customer who insists — but do not make it the default.
  6. The hard part is not transport, it is templates. Business-initiated WhatsApp messages must use pre-approved templates. Our Message{Title, Body, Link} is free-form and does not map 1:1 onto a template. That is the actual design work.

0. What already exists in this repo

Worth reading before designing anything.

File What it is State
go/internal/esign/adapters/phone_whatsapp.go Real Meta Cloud API sender. POSTs to graph.facebook.com/v20.0/{phone_number_id}/messages. Supports both an approved AUTHENTICATION template (body param + copy-code button) and plain text. Normalises Indonesian MSISDNs (0…62…, bare 8…62 8…). Masks the token in errors. Working
go/internal/esign/adapters/wa_settings_pg.go Settings store: esign_wa_settings, single row, deployment-wide. Token is age-encrypted at rest. Settings are live-loaded per send (no restart to change). Working
go/internal/httpapi/handlers_esign.go GET/PUT /api/v1/admin/esign/whatsapp (+ /test). GET masks the token; PUT with empty api_key keeps the stored one — same convention as SMTP. Working
web/src/features/admin/data.ts Admin UI is wired to those endpoints. Working
go/migrations/00060_user_phone.sql users.phoneevery user can already have a number on file. Exists
go/internal/esign/adapters/inviter_whatsapp.go WhatsAppInviter for external-signer invites — a stub. Logs "not delivered" and returns nil. Stub
go/internal/notify/** The notification system. Channels today: email (SMTP) and push (FCM/APNs). No WhatsApp. No WA

So: the authentication use case (OTP) is built; the utility/notification use case is not; and the invite use case is a stub that pretends to work.


1. The three routes

A. Meta Cloud API (direct) B. BSP — Mekari Qontak C. whatsmeow (unofficial)
Official? ✅ Meta's own API ✅ Official BSP reseller ❌ Reverse-engineered client
Library needed None — HTTPS + JSON None — HTTPS + HMAC go.mau.fi/whatsmeow (MPL-2.0)
Already in repo yes, working Auth is (eSign HMAC) no
Per-message cost (ID, utility) Rp 367 Rp 295 Rp 0
Monthly/subscription Rp 0 Qontak plan fee Rp 0
Sender identity A verified WABA number A verified WABA number A real WhatsApp account you own
Setup Meta Business verification, WABA, template approval Contract + onboarding, they handle Meta Scan a QR with a phone
Ban risk none none real, and unappealable
Ops burden low (stateless HTTP) low high (session store, re-pair, reconnects)
Rate/throughput Meta tiers (1k→100k+/day) same unofficial, unpredictable
Group chats, reading replies limited/paid limited ✅ full

Route A — Meta Cloud API direct (recommended)

The Cloud API is free to access — Meta charges per delivered template message, no platform fee, no minimum. Since 1 July 2025 billing is per message (not per 24h conversation), per category and per market.

Indonesian rates: utility ≈ Rp 367, authentication ≈ Rp 438, marketing much higher. Utility/auth have volume tiers that lower the rate.

⚠️ Change landing in ~7 weeks: from 1 October 2026, service messages and utility templates sent inside the 24-hour customer-service window become billable — the free in-window ride ends. Meta publishes final country rates by 1 September 2026. For us this is close to a non-event: our notifications are business-initiated (nobody messages us first), so they are template-billed either way. Worth knowing before anyone builds a cost model on "free in-window".

Why this route: the adapter exists, the admin surface exists, the token encryption exists, the MSISDN normalisation exists. Adding notifications is mostly a notify.Channel implementation and a template map — not an integration.

~~Route B — BSP (Mekari Qontak)~~ — RULED OUT 2026-08-14 (user decision)

Struck on vendor-conduct grounds, not price. Recorded because the reasons generalise into a hard rule for any future vendor (§7):

  1. Contract bait-and-switch. An NDA was sent for review; on approval they presented a partnership agreement to sign, described as "basically the same thing". Caught on reading; they produced the actual NDA when challenged.
  2. 🔴 Number hostage. Under Qontak, ending the relationship means losing access to our own WhatsApp number.

Reason 2 is the one with teeth for architecture — and, as §7 shows, it is a choice the BSP makes, not a rule Meta imposes. That makes it a red flag about the vendor rather than a cost of doing WhatsApp.

⚠️ Wider blast radius, flagged for awareness, not a recommendation: Mekari is currently our e-sign PSrE providerESIGN_PROVIDER=mekari, esign/adapters/sealer_mekari.go, and the WhatsApp OTP (il) channel that external signers use today. "Never again" therefore reaches further than this project. The good news is the architecture already anticipated it: per MEKARI.md, all Mekari-specific protocol lives in one adapter, and switching to Peruri/BSrE is one new adapter plus one switch case, with no changes to the e-sign service, DMS, workflow or handlers. Separate decision, separate piece of work — noted here only so it is not discovered later.

Route C — whatsmeow

The library the question named. Honest assessment:

It is genuinely good. Go-native, MPL-2.0, ~1,600 commits, actively maintained by Tulir Asokan, and it is what powers mautrix-whatsapp and Beeper — i.e. it is battle-tested at real scale by people who depend on it. It implements the multi-device protocol properly (Signal-based E2E, media, groups, receipts, app-state sync). If we were building a WhatsApp client, this would be the correct choice with no close second. Notably it does not support broadcast lists (neither does WhatsApp Web).

Why it is still wrong for production notifications here:

  1. It is an unofficial client. Automated use violates WhatsApp's ToS. Meta's enforcement runs on volume anomalies, device fingerprinting and session patterns, and has escalated hard through 2025 (millions of accounts actioned). A ban is silent, immediate, and has no support queue.
  2. The 14-day cliff. A linked device is logged out if the primary phone does not come online for ~14 days — plus forced re-pairs on reinstall, re-registration or a security event. A server-side WhatsApp session is not a set-and-forget thing. Somebody must own a physical SIM and a phone that stays alive, forever, and re-scan a QR when it dies. Compare: the Cloud API token is a config field.
  3. Failure mode is invisible. Our notify fan-out is deliberately best-effort — Notify collects channel errors and drops them. A dead WhatsApp session would therefore fail silently and indefinitely. We have been bitten by exactly this shape before (SendEmailOnly swallowing every error; safeURL dropping every relative link so no email ever carried a working link).
  4. Compliance optics. Obscura is sold to Indonesian government and enterprise buyers, with certified PSrE signing and ISO-16175 records claims. "Our notification channel is a reverse-engineered client logged in as a staff member's personal WhatsApp" is a bad sentence in a security questionnaire.
  5. MPL-2.0 is fine but not free of obligation. Linking is unproblematic for our commercial product; modifications to whatsmeow's own files must be published. Only a constraint if we patch it — worth a note in LICENSING.md if we ever vendor it.
  6. No template guarantees. The formal template/consent machinery does not apply — which sounds like freedom and is actually why unofficial senders get reported and banned.

Where whatsmeow would genuinely be the right call:
- an on-prem / air-gapped customer who cannot or will not open a Meta account;
- a customer who wants notifications from their own existing number with no business verification;
- demo and internal testing — no cost, no approval, instant;
- anything needing inbound messages or group chats.

If we go there, do it as notify/adapters/whatsapp_meow.go behind the same Channel port — not as a fork in the road. Off by default, clearly labelled unsupported. Ready-made wrappers exist if we would rather run it as a sidecar than embed it: wuzapi (Go, whatsmeow, REST — closest to our stack), WAHA (Docker-first HTTP API), Evolution API (Baileys/Node, n8n-friendly). All shift the same risk into a container instead of removing it.


2. Cost math at our actual scale

This is the argument that settles it.

Prod today: 11 users, 7 notification categories (workflow, document, document_expiry, records_disposition, records_transfer, correspondence, meterai_quota).

Scenario Msgs/month @ Rp 367 (Meta) @ Rp 295 (Qontak)
Realistic — 15/user/month 165 Rp 60,555 Rp 48,675
Heavy — 50/user/month 550 Rp 201,850 Rp 162,250
10× headroom (110 users) 5,500 Rp 2,018,500 Rp 1,622,500

whatsmeow saves us about Rp 60k/month at today's prod scale — under 0.02% of a single Rp 500jt core licence. That is the price of removing ban risk, the 14-day session cliff, and the QR-re-pair chore. Not a close call.

(Note we will already be paying the authentication rate for e-sign OTP the moment that gateway is switched on in prod — so a WhatsApp line item is coming regardless.)


3. The real constraint: templates, not transport

Sending is the easy 20%. The constraint is Meta's rules for business-initiated messages — which is all of ours, since a user never messages us first.

  • Every business-initiated message must use a pre-approved template, submitted to Meta and reviewed (minutes to ~24h). Rejections are common for anything vague or marketing-flavoured.
  • Templates are fixed text with numbered parameters. Parameters cannot contain newlines or tabs, and have length limits. Our Message.Body is free-form prose written for email — it will not fit.
  • Category matters: notifications are utility (cheap). Get it classified marketing and the price roughly doubles and stricter opt-in rules apply.
  • Deep links go in a URL button with a dynamic suffix — not inline in the body.

Design implication: we need a per-category template map, mirroring the email template registry (notify/domain/emailtpl.go, slugs like TemplateSignInvite, TemplateShareCode). Roughly one approved WhatsApp template per notification category, with 2–3 parameters (who/what/when) plus a URL-suffix button. Message gains structured fields, or channels get a per-channel renderer — same shape as the email template work.

⚠️ The link trap, again. The URL-button suffix must be built from the absolute APP_BASE_URL. This is precisely how we shipped emails where no link ever worked.


4. Where it plugs in — and four traps

The port is already right: notify/app.Channel is Name() string + Send(ctx, Recipient, Message) error, fan-out is best-effort after the in-app row is committed, and the in-app notification stays the source of truth. A WhatsApp channel is a clean fit.

Four things that will bite:

  1. 🔴 Recipient has no phone. It is {UserID, Email} only. users.phone exists (mig 00060) but notify never loads it. Every Notify call site (7 in wire.go, 7 in jobs.go, 1 in request_wiring.go) builds recipients — the phone must be threaded through, or the channel must look it up.

  2. 🔴 Prefs gating is hardcoded to email. Service.Notify does a literal if ch.Name() == "email" before applying EmailEnabled / Frequency. A WhatsApp channel added today would ignore every user preference and every frequency setting — muted categories are still respected, but a user who set "daily digest" would get instant WhatsApps. This needs generalising to per-channel prefs before a second gated channel exists, not after. New prefs fields also need the PUT-prefs validation and the profile UI.

  3. ~~🔴 Settings are deployment-wide, single-row.~~ RESOLVED — see §5. esign_wa_settings has no tenant column, but it does not need one: go/migrations/00116_esign_wa_settings.sql is in the tenant migration set, so Cloud's schema-per-tenant model gives every tenant its own row already. With customer-owned WABAs that is exactly right, and the shared-relay hazard from SMTP cannot recur. Still worth doing: promote the settings out of e-sign into a shared platform/whatsapp now that notifications use them too, with the e-sign sender as one caller.

  4. ⚠️ Silent failure. Channel errors are dropped by design. WhatsApp adds new silent-failure modes email does not have: template rejected/paused, number not on WhatsApp, user never opted in, quality rating throttling. Needs at minimum a logged error and ideally a per-send outcome the admin page can show — same lesson as SendEmailOnly.

Plus: opt-in. WhatsApp requires demonstrable user consent. For a staff-facing DMS that is a profile toggle (default off) with recorded consent — which fits the prefs work in trap 2, and is the honest reason not to just switch it on for everyone.


5. Vendor rule: own the Business Manager

The Qontak hostage problem is real, but it is not how WhatsApp works — it is how that vendor chose to work. The mechanics:

  • A number can be migrated between WABAs/BSPs only if the same Meta Business Manager owns both source and destination. Ownership of the Business Manager is therefore the whole game.
  • Meta's rules explicitly forbid a BSP from refusing or delaying the 2FA disablement or the porting process.
  • What survives a migration: the number, approved templates, display name, quality rating and messaging limits. Chat history does not. The source WABA keeps sending until the destination registers the number, so there is no downtime.

So the hostage scenario happens when the BSP owns the Business Manager and you are merely a guest inside it. Hence the rule, which should outlive this project:

🔒 Obscura (or the customer) owns the Meta Business Manager and the WABA. A vendor gets access, never ownership. Any vendor who requires otherwise is disqualified on that fact alone.

With Route A this is true by construction — direct Cloud API means the Meta onboarding is done by whoever owns the assets, and they sit in that party's Business Manager from day one. It is also the cheapest insurance we will ever buy: had this rule existed earlier, the Qontak arrangement would have been rejected at the proposal stage rather than discovered at the exit.

✅ Decided 2026-08-14: each customer brings their own WABA

The strongest version of the rule — the customer owns their Business Manager, their number, their templates, their quality rating and their consent relationship. It also means we can never become the thing Mekari was to us: there is no exit we could block, because we never held anything.

What it costs us: almost nothing structurally. Verified — esign_wa_settings is created by go/migrations/00116_esign_wa_settings.sql, which is in the tenant migration set, not go/migrations/control. Under Cloud's schema-per-tenant model each tenant already gets its own settings row automatically. No tenant column, no migration, no new plumbing — §4 trap 3 dissolves rather than being fixed. The one-relay-sends-for-everyone hazard we hit with SMTP cannot arise here, because the row was never shared.

What it buys:

  • No tenant ceiling. The ~20–25 numbers per Business Manager limit was only a constraint if we owned them. It no longer applies to us at all.
  • Quality-rating isolation. One customer's block rate cannot drag down another customer's deliverability. This is the same isolation doctrine the rest of the codebase already enforces, extended to a channel that would otherwise have been a shared fate.
  • Correct sender identity for free. An external signer sees the agency that asked them to sign, not "Obscura" — which is exactly what §9 says drives block rate down.
  • The liability sits with the party that has the customer relationship, which is also the party that can actually answer for it.

What it costs the customer — and this is the real bill:

  • Meta Business verification, a dedicated number, and a WABA. Non-trivial paperwork for an Indonesian government agency.
  • 🔴 Template approval becomes per-customer. Templates belong to a WABA, so we cannot approve them once centrally. Consequences for the design:
  • We ship a template pack — exact names, categories, language code, body text and parameter order — that the customer's admin submits inside their own Business Manager. It is onboarding documentation, and it is now on the critical path for every deployment.
  • Template names must be configurable per tenant, not hardcoded. The existing WhatsAppSettings.TemplateName already establishes this pattern for the OTP template; extend it to one configurable name per notification category, with our documented default as the suggested value.
  • A customer who edits the wording when submitting will drift from what our renderer expects. The parameter count and order is the contract — document it as such, and validate on the admin test-send.
  • Their own Meta support relationship when a template gets paused.

Which makes the self-hosted provider a product tier, not a dev toy. Customers who will not or cannot complete Meta verification — small agencies, air-gapped sites — still get WhatsApp via WHATSAPP_PROVIDER=wuzapi on their own number. The provider-agnostic port (§8) now earns its keep on the first day rather than hypothetically, and the GPL sidecar posture (§6) is what makes that shippable.

Support consequence: when messages stop, it is their WABA and their quality rating. Error surfacing therefore has to be specific — "your template obscura_sign_invite is paused by Meta" beats "send failed", and the existing admin test-send button is the right place to prove it. This makes §4 trap 4 (silent failure) more important, not less.


6. 🔴 The license chain — the finding that shapes the whole design

Verified against whatsmeow's current go.mod (main, Go 1.25):

module go.mau.fi/whatsmeow           MPL-2.0  
require go.mau.fi/libsignal v0.2.2   GPL-3.0  🔴

whatsmeow itself is MPL-2.0 and perfectly usable. But it depends directly on go.mau.fi/libsignal (a fork of crossle/libsignal-protocol-go), which is GPL-3.0. Go builds link statically, so:

Importing whatsmeow into obscura-server and shipping that binary to an on-prem customer would make the combined work GPL-3.0 — obliging us to offer Obscura's complete source to every customer we ship to.

For a proprietary product with a paid licence model and node-locked enforcement, that is a showstopper, not a footnote. Three nuances that decide where it is safe:

Deployment Distributed? GPL-3.0 obligation
Demo / dev / internal No None. GPL triggers on distribution, not use. Free to embed.
Obscura Cloud (we host) No None. GPLv3 has no network/SaaS copyleft — that is AGPL, and this is not AGPL.
On-prem (dms1, customer VMs) Yes 🔴 Full copyleft on the combined work.

This is why the second adapter must be a sidecar. A separate process, shipped as its own upstream container image, talking to us over HTTP, is the standard arm's-length arrangement — we distribute an unmodified open-source container alongside our binary rather than linking GPL code into it. Which is also what solves whatsmeow's pairing, session-store and reconnect problems. The licence constraint and the operational one point at the same architecture.

⚠️ Not legal advice. This is a documented reading of the licence chain, and it should have a lawyer's sign-off before anything GPL-adjacent ships to a customer. The safe subset needing no sign-off at all — demo, dev, Cloud — is large enough to build and prove the whole thing.


7. whatsmeow alternatives — the actual landscape

Asked directly: is there something better than whatsmeow? For native Go, no — it is the only credible option, and the rest of the field is Node or Python. But the sidecar framing changes the question from "which library" to "which container", and there the answer is not whatsmeow-the-library at all.

Option Lang Licence Verdict
wuzapi Go MIT (wraps whatsmeow) Best fit. REST over whatsmeow, Docker image, Postgres or SQLite session store, multi-session. Closest to our stack; we already run sidecars (stego, mammoth). Ships with an honest ban warning.
whatsmeow (embedded) Go MPL-2.0 + GPL-3.0 dep Correct library, wrong packaging — see §6. Fine embedded for demo/Cloud, not for on-prem.
WAHA Node Apache-2.0 core ⚠️ The "free" is thinner than it looks: core is 1 session, text-only, no media; media + unlimited sessions is ~$19/mo (Plus), source access $99/mo (PRO). Media matters to us — we notify about documents.
Evolution API Node Apache-2.0 + brand conditions (logo/copyright preservation, usage notification) Feature-rich, and interestingly speaks both Baileys and the official Cloud API. But Node + Baileys licence chain + attribution strings attached.
Baileys Node/TS MIT — but libsignal is GPL-3.0 Same trap as whatsmeow, unresolved. Maintainers are migrating to a Rust replacement, rolling out across releases rather than in one version — so the dependency tree must be checked per exact version shipped. 6.7.x stable, 7.x still RCs.
whatsapp-web.js Node Apache-2.0 Drives a real Chrome via Puppeteer. Heaviest and most fragile of the lot. No.
venom / wppconnect Node Apache-2.0 / MIT Same browser-automation family. No.
neonize Python — (whatsmeow bindings) Same GPL chain, plus a Python runtime. No.

Conclusion: wuzapi is the whatsmeow alternative we want — it is whatsmeow, MIT-wrapped, in a container, with the session store already solved. The library question and the packaging question have the same answer.


8. Provider-agnostic — one port, one active provider

Corrected 2026-08-14 (user): not two channels running side by side — one swappable provider, exactly like PSrE / e-Meterai / TERA.

Copy selectSealer verbatim in spirit: one env var, one switch, and a no-op fallback with a logged warning when unconfigured (ESIGN_PROVIDER=mekari with no credentials → no-op sealer, boot still succeeds). That pattern is proven here and already survives three seal kinds.

WHATSAPP_PROVIDER = cloud | wuzapi | none      (default none)

platform/whatsapp.Sender           the port
  ├── sender_cloud.go    Meta Cloud API   (prod, on-prem, Cloud)
  ├── sender_wuzapi.go   wuzapi sidecar   (demo, dev, air-gapped)
  └── sender_noop.go     logs, delivers nothing

One notify Channel sits on top of that port, so notify never learns which provider is live — same as the e-sign service never learning it is talking to Mekari. The e-sign OTP sender and the signer inviter become callers of the same port instead of owning their own client, which also retires the inviter_whatsapp.go stub.

Worth stealing from e-sign while we are here: per-role provider overrides (EffectiveMeteraiProvider, STAMP_PROVIDER) exist because one deployment legitimately wants different providers for different jobs. The WhatsApp equivalent is real — a customer could plausibly want Cloud API for external signers and a self-hosted number for internal staff notifications. Cheap to allow for now, expensive to retrofit.

🔴 The catch survives the correction, and the port must absorb it. The providers do not accept the same content:

🔴 The catch is that they do not accept the same content. This is the one place where "just write two adapters" is misleading:

Cloud API wuzapi / whatsmeow
Business-initiated message Pre-approved template, fixed text, numbered params, no newlines in params Any free-form text
Deep link URL button with dynamic suffix Inline URL in the body
Consent Meta-mandated opt-in none enforced
Failure modes template paused, number not on WhatsApp, quality throttling session dead, re-pair needed, banned

So the port cannot carry Message{Kind, Title, Body, Link} and let each provider improvise — and it must not leak Cloud-API template concepts either, or swapping in wuzapi means rewriting callers. The port carries structured intent; the provider renders it: Cloud emits (template_name, params[], url_suffix), wuzapi emits a formatted string. Same shape as the existing email template registry (notify/domain/emailtpl.go + emailrender.go) — we have built this exact thing once already and should copy its structure.

This is the one place the WhatsApp port is harder than the sealer port. Peruri and Mekari both accept "here is a PDF, seal it". Cloud API and wuzapi do not accept the same message, so the abstraction has to be drawn at intent rather than at payload. Draw it at payload and it will leak.

Consequence to accept deliberately: the providers are not behaviourally identical, so the demo will not prove out the production path. A message that sends fine over wuzapi can be rejected by Meta's template review. Whatever we validate on the demo, the templates still need a real Cloud API dry run before an on-prem customer sees them.


9. Consent, actually — the rules are looser than the vendors say

Correction to §"Two blockers" item 1. That claim came from BSP compliance blogs, which sell managed compliance and price the fear accordingly. Checked against the primary sources, the position is:

① Meta's platform rule changed in November 2024. A general opt-in collected on any channel now satisfies it — it no longer has to be WhatsApp-specific, and it no longer has to be collected by us on WhatsApp. What remains required is modest: identify the business by name, make clear the person will receive messages from you, and have their number. "They gave us their number so we could route a signature to them" fits this.

② Indonesian law does not require consent here at all. UU PDP 27/2022 Pasal 20(2) lists six lawful bases, and consent is only the first. Pasal 20(2)(b) covers processing "to fulfil the obligations of an agreement to which the data subject is a party, or to fulfil a request of the data subject." A signing invite is squarely inside it. Meta's own policy defers to local law, and local law says contract performance is sufficient.

③ The 2FA observation is exactly right, and it generalises. Codes arrive over WhatsApp without any consent checkbox because entering your number is the opt-in — contextual, implied by the act. Same structure as a signer being routed a document they were asked to sign.

So: no rule needs breaking, which is the cheapest possible outcome. The reason to stay inside the lines is not virtue, it is that the line is drawn generously enough to cost us nothing to stay behind.

What actually enforces this (and it is not a rulebook)

Nobody audits consent records. Enforcement is mechanical and user-driven:

  • Quality rating is recomputed continuously over roughly the last 7 days of block rate, spam reports and engagement.
  • A template that draws complaints is auto-paused — 3h, then 6h, then longer.
  • Number-level: Low quality drops your messaging tier immediately; Low for 7 consecutive days drops it again. Recovering takes 7 consecutive good days.

So the thing to optimise is block rate, not paperwork.

🔴 The one place the 2FA analogy breaks

With 2FA, the recipient acted — they typed their number seconds earlier, so they recognise the message. With an external signer, a third party supplied the number and the signer has never touched our system. They may not recognise the sender at all. That gap does not create legal risk; it creates block risk, which is the risk that actually bites.

The mitigation is content, not policy — and it is cheap:

  • Name the human who requested it, first line: "Budi Santoso meminta tanda tangan Anda pada dokumen …" A named colleague is recognisable; "Obscura" is not.
  • Name the organisation the requester belongs to.
  • Reference the specific document — that is what makes it a utility message rather than a cold approach.
  • Include an opt-out line. People who can leave quietly do not report; a reply-STOP costs one line and protects the quality rating.
  • Never message a number that has no live transaction with us.

Do those five things and the block rate stays near zero, which is the only compliance that has teeth.

⚠️ Not legal advice — a real read of UU PDP for a government-sector customer should have counsel's eyes, once, before prod. But the architecture does not change either way: consent state is a field, not a design.


10. Configuration: env vs admin UI

The house already answered most of this. SMTP's pattern is env-SEEDS / row-DECIDES, and wire.go:805–847 is the blueprint worth copying line for line:

  • SMTPSettings.Configured separates "an administrator set this up" from "the singleton row exists with defaults".
  • While Configured == false, the deployment env wins — an install upgrading into the migration behaves exactly as before.
  • Once Configured == true, the row wins and the env is ignored, and the boot log says so in those words.
  • Cloud takes a different branch entirely: smtp_settings is a per-tenant table, so there is no deployment-wide row — it wires a resolver (newTenantSMTPCache.get) and the env is only the fallback.

The split I recommend

The provider selector is env. Everything else is the admin UI, per tenant.

Setting Where Why
WHATSAPP_PROVIDER = cloud|wuzapi|none env Deployment topology, not operational config — see below
WUZAPI_BASE_URL env The sidecar's address is infrastructure
Graph API base override env default, row may override Already works this way (WhatsAppSettings.BaseURL), used by tests
Phone number ID row / admin UI Customer-owned WABA (§5) — only the tenant knows it
Access token row / admin UI, encrypted Same, and it rotates
Template names (one per category) row / admin UI 🔴 Per-customer template approval (§5) means these differ per tenant. Cannot be env.
Per-user opt-in / phone row Ordinary user data

Why the provider must not be a UI dropdown:

  1. It has a deployment-topology dependency the UI cannot satisfy. Switching an SMTP host needs no new container; switching to wuzapi needs a wuzapi service to actually exist in the compose file. An admin flipping a dropdown to a provider that was never deployed produces a silently dead channel — and notify's fan-out drops channel errors by design, so nobody finds out.
  2. It is a distribution decision, not an operational one. Whether the GPL-3.0 sidecar ships at all is §6's question. An administrator should not be able to enable a code path whose legal posture depends on what we shipped.

But WHATSAPP_PROVIDER being env means it is process-wide, and that is a known Cloud failure mode — the env-vs-settings audit already flags "Cloud e-sign is process-wide (one contract for every tenant)" as an open problem, and this is the same shape. With customer-owned WABAs, tenant A on Cloud API and tenant B on a self-hosted number is a legitimate configuration.

Resolution, matching SMTP's Cloud branch:

env declares what this deployment can serve; the tenant row picks which of those it uses.
A row naming a provider the deployment does not offer → no-op plus a loud log, never a silent half-configured state. The admin dropdown lists only the available providers.

🔴 Two gaps in the WhatsApp settings that exist today

Both are live in the shipping OTP gateway, not hypothetical, and both are already-solved problems elsewhere in the codebase:

① The paste-poisoning defence was never applied here. PutWhatsAppSettings sanitises with strings.TrimSpace and nothing else — which is precisely the call that failed during the SES outage. Per notify/adapters/settings_clean.go: the pasted hostname carried U+202F followed by U+2060, TrimSpace was already applied and did nothing (U+2060 is a Cf format character, not whitespace, so trimming works inward, hits the joiner and stops), and prod mail went fully dark while the admin screen rendered the broken value identically to the correct one.

WhatsApp config is entirely paste-sourced from Meta's dashboard — a numeric phone number ID, a ~200-character access token, template names. It is the same hazard with a longer, less inspectable string. Reuse stripInvisible. It already exists, it is already tested, and it is one import away.

② The token can be stored in plaintext. wa_settings_pg.go falls back to storing the access token unencrypted when no master key is configured (it checks for the age armor header on the way out and treats anything else as plaintext). SMTP faced the identical choice and went the other way: with a nil cipher it refuses to persist the password rather than writing it in the clear into a table operators routinely dump, leaving the env as the unencrypted-by-necessity path. WhatsApp should match SMTP — a long-lived Meta access token sitting in a pg_dump is a real credential leak, and it becomes more valuable once it can send to a customer's entire signer population rather than just OTPs.

Two more inherited lessons worth writing down

  • Keep live-load per send. phone_whatsapp.go re-reads settings on every send, which means it does not have SMTP's worst trap — "a SQL fix does NOT re-bind — repair via PUT /admin/smtp". Do not "optimise" this into a cached config at boot.
  • A bad row must degrade to a no-op, not an outage. SMTP's row became a single point of total mail failure. Notify's best-effort fan-out contains the blast radius here, but that same containment is what makes it invisible — so this makes §4 trap 4 (error surfacing) mandatory rather than nice-to-have.
  • ⚠️ And for whoever sets the env var: a literal environment: block in compose beats --env-file. That has bitten this project before.

11. Recommendation

Route A, reusing what we have. With scope now covering external parties, the order matters — internal first, external once consent and sender identity are settled.

Phase 1 — staff notifications (no new policy questions).
1. Promote the WhatsApp client out of esign/adapters into a shared platform/whatsapp (settings, encryption, MSISDN normalisation and error masking all come along free). Leave the e-sign OTP sender as one caller.
2. Generalise prefs to per-channel first (trap 2) — small now, a migration later, and a silently-ignored preference if skipped.
3. Add Recipient.Phone, sourced from users.phone.
4. Map notification categories → approved utility templates, mirroring the email template registry. Submit for approval early — that is the long pole, though utility review is usually under 6 hours with a <5% rejection rate.
5. Ship opt-in, off by default, with a visible admin test-send and a real error surface (trap 4).
5b. While in the settings code, close the two live gaps in §10 — apply stripInvisible to the pasted values, and refuse to persist the token when no cipher is configured. Both are small, both are already-solved problems elsewhere, and both are shipping unfixed in the OTP gateway today.

Phase 2 — external parties. No longer blocked on consent (§9); only on sender identity.
6. Point the inviter_whatsapp.go stub at the port. Worth doing early on its own merits: external signers currently get nothing while the code logs as if a channel existed. Even under email-only that stub is a live bug.
7. Write the five block-rate rules from §9 into the invite template itself — requester's name, organisation, document, opt-out line. That is the consent work; there is no separate policy gate.
8. ~~Settle per-tenant sender identity~~ — settled: customer-owned WABA (§5). The remaining work is onboarding, not code: write the template pack (names, categories, language, body text, parameter order) that each customer submits in their own Business Manager, and make template names configurable per tenant rather than hardcoded.

Phase 0 — the wuzapi sidecar, in parallel and cheap. Nothing here blocks on Meta onboarding, template approval or consent policy, so it is the fastest way to have something working to look at: stand up wuzapi in the demo compose, write whatsapp_wuzapi.go against the same Channel port, pair a throwaway number. It exercises the plumbing — Recipient.Phone, per-channel prefs, the renderer split — while the Cloud API paperwork proceeds. Demo and dev only: no distribution, therefore no GPL obligation (§6), and a ban costs us a burner SIM.

Build both, in this order: Phase 0 proves the plumbing → Phase 1 makes it real → Phase 2 opens it to external parties.


12. Open questions

  1. ~~Scope~~ — settled 2026-08-14: everything, per-category calls later.
  2. ~~External-party consent~~ — closed 2026-08-14, see §9. Permitted under Meta's post-Nov-2024 rule and UU PDP Pasal 20(2)(b). Becomes a template-content task, not a policy gate. Optional: counsel's read before prod for government customers.
  3. ~~Who owns the WABA?~~ — closed 2026-08-14: each customer brings their own (§5). No tenant ceiling, quality-rating isolation, correct sender identity, and we never hold anything a customer would have to fight us for. Cost lands on customer onboarding and per-customer template approval.
  4. 🔴 Does the GPL-3.0 sidecar posture get legal sign-off for on-prem, or is whatsmeow demo/dev/Cloud-only forever? Everything up to Phase 1 can be built without the answer, so this is not blocking yet — but it decides whether air-gapped customers ever get WhatsApp.
  5. Prod or demo first? (Phase 0 makes "demo first" nearly free.)
  6. ~~Mekari Qontak~~ — closed, see §1 Route B.
  7. Is one-way enough, or does anyone want replies/inbox? Note this no longer implies a BSP: wuzapi/whatsmeow does inbound natively, and Cloud API supports it too — it is a product question now, not a vendor one.
  8. ~~Does "never again" extend to Mekari as our e-sign PSrE?~~ — closed 2026-08-14: yes, it does. Tracked separately from this project; scoping note below.

13. Mekari as e-sign PSrE — decided out, scoping note

Decision 2026-08-14 (user): "never again" extends to Mekari as the e-Sign PSrE. Not part of the WhatsApp work, recorded here so it is not lost.

The good news is that this is mostly a configuration change rather than a build. Per MEKARI.md, all Mekari-specific protocol is confined to one adapter, and the Peruri adapter already existsselectSealer in wire.go already has a case "peruri" constructing NewPeruriSealer from cfg.Peruri.Resolved(). There is also an existing peruri-esign worktree. So the shape of the work is:

  1. Flip ESIGN_PROVIDER (and METERAI_PROVIDER where it follows) from mekari to peruri, per deployment.
  2. Verify the Peruri adapter against the three seal roles — sign, e-Meterai, stamp — because the seal-flow audit found every bug in that area came from one kind inheriting another's path incompletely. Diff the three kinds; do not assume sign coverage implies meterai coverage.
  3. 🔴 Check the Peruri production endpoints specifically. Boot already refuses a prod-meterai/staging-Sign-IT mix because that combination produces valid-looking, legally worthless seals — but the readiness note records that no production Sign-IT URL exists in code, so this needs confirming before a prod cutover rather than after.
  4. Decide what happens to in-flight envelopes signed under Mekari. Existing signatures stay valid — they are Peruri-or-Mekari-issued certificates already embedded in the PDFs — but any pending Mekari ceremony (PendingSeal rows with provider = 'mekari') will have nowhere to complete once the credentials are withdrawn. That is the one part that is genuinely a migration and not a config flip.
  5. The external-signer OTP currently rides Mekari's il (WhatsApp) channel. Once the WhatsApp port here is wired, that dependency disappears on its own — this project removes one of the reasons the Mekari relationship was hard to exit.

Sources