Skip to main content

ADR-0008: WhatsApp Ingress — Cloud API Direct (vs 360dialog BSP)

Status: Accepted Date: 2026-04-26

Context

PRD §2.1 + ADR-0001 originally pinned 360dialog as the WhatsApp Business Solution Provider (BSP). That choice was made during the pre-implementation phase based on the BSP's reputation for clean multi-tenant API access and developer-friendly webhooks, with the implicit assumption that the BSP layer would absorb Meta-side account-management complexity for a small team.

When M4 (WhatsApp channel — first slice) reached the Plan Walkthrough phase, Adrian raised three concerns that forced revisiting the choice:

  1. The BSP value-add isn't visible. 360dialog charges €49/month minimum and explicitly states their offering is "pure API access with no UI" — the same shape Meta provides directly. For a single-engineer-with-AI-collaborator team that doesn't need hand-holding, the monthly fee buys nothing operational.
  2. Phone-number provisioning friction. 360dialog's onboarding wanted Adrian's personal mobile number for verification, which isn't acceptable. The path-to-production via 360dialog requires a separate number that BSP-side processes complicate.
  3. Cost compounds for pilot economics. €49/month × 12 = €588/year in BSP fees alone, before per-message Meta charges. For a pre-revenue Phase 1 pilot targeting one Nairobi spa, that's non-trivial.

Research surfaced three viable paths:

  • Cloud API direct (Meta's first-party): €0/month platform fees + per-message Meta rates; full control of webhook URLs, signing secrets, access tokens; multi-tenant by phone_number_id.
  • Twilio as BSP: similar abstraction layer to 360dialog, but using Twilio's REST API (different webhook shape + message-id format); markup on top of Meta rates (~10-30%).
  • 360dialog BSP: status quo, €49/month + Meta rates passthrough.

For the development phase Meta provides up to 2 free test phone numbers per app — US-coded (+1 555 area) test numbers that can message up to 5 verified test recipients. This eliminates dev-time phone-number cost entirely and lets us validate the full integration loop on day one.

For production, the cleanest phone-number provisioning is a Twilio long-code ($1-3/month) registered with WhatsApp Business Platform via SMS OTP — once verified, the number is "claimed" by the WABA and routes only through Meta's WhatsApp infrastructure; Twilio's role ends after verification.

Decisions

D1. Use WhatsApp Cloud API direct as the inbound channel

The Ratiba backend talks to Meta's first-party Cloud API (graph.facebook.com/v{N}/{phone_number_id}/messages and /api/v1/webhooks/whatsapp for inbound). No BSP layer.

The webhook signature scheme is Meta's standard X-Hub-Signature-256: sha256=<hmac-hex>, identical to Facebook Graph API webhooks across the platform. The 24h customer-initiated conversation window applies — outbound replies within that window are freeform text, no template approval needed.

Rationale:

  • Cost: €0/month platform fee vs €49/month with 360dialog. ~€600/year saved for the lifetime of the project.
  • Control: full ownership of webhook URLs, access tokens, signing secrets. No BSP-side configuration that requires cross-account coordination.
  • Multi-tenancy fit: cleaner than the BSP model. Per-tenant credentials are just phone_number_id + access_token + WABA_id; HMAC verification uses a single project-level app_secret (one secret, all tenants). The BSP model required per-tenant webhook secrets, increasing the per-tenant credential surface.
  • No abstraction tax: Meta's webhook payload is the source of truth. BSPs wrap it; we'd have to learn both shapes.

D2. HMAC verification uses a project-level WHATSAPP_APP_SECRET

The Meta App's secret (visible in App Dashboard → Settings → Basic) is the HMAC key for every inbound webhook regardless of which tenant the payload addresses. This is structurally different from the per-tenant webhook-secret model 360dialog provides.

Settings.whatsapp_app_secret: SecretStr — required at boot. Stored in backend/.env for development; injected via runtime env var or secret manager in production.

Per-tenant routing happens AFTER signature validation, by reading the phone_number_id from the (now trusted) payload and mapping it to a tenant via the indexed lookup on tenants.whatsapp_phone_number_id.

D3. Per-tenant credentials on public.tenants

Three new columns on public.tenants (additive migration, M4 Task 1):

ColumnTypePurpose
whatsapp_phone_number_idVARCHAR(50)Meta's identifier for the tenant's WhatsApp number; the inbound to. UNIQUE-when-not-null partial index for the channel-boundary IdentityResolver lookup.
whatsapp_access_tokenTEXTLong-lived System User token (production) or 24h temp token (dev). Encrypted-at-rest placeholder; encryption layer is M3+ work.
whatsapp_business_account_idVARCHAR(50)The WABA ID; useful for tenant-level Meta API operations (template management, business-profile updates).

The per-tenant whatsapp_number (human-facing E.164) and whatsapp_phone_number_id (Meta's internal ID) are distinct. The phone_number_id is what the channel-boundary resolver matches against; the human-facing number is what customers dial.

D4. Production phone-number provisioning via Twilio long-code

Production WABA numbers are provisioned via:

  1. Buy a Twilio long-code with mobile capability (~$1-3/month; for the Kenyan pilot, +254 numbers when Twilio offers them; else any country code that WhatsApp accepts for the target market).
  2. Register the Twilio number with WhatsApp Business Platform via SMS OTP from Meta's App Dashboard → Add Phone Number flow.
  3. Once verified, the number is "claimed" by the WABA. SMS/voice on the Twilio number from that point on bypass Twilio's normal delivery and route only through Meta's WhatsApp infrastructure.
  4. Optionally release the Twilio number after claim — once verified, Twilio's role is finished.

Alternative for production: a real SIM card (any cheap pay-as-you-go SIM in the operating country) — even cheaper, but requires physical possession at verification time.

The phone-number choice is per-tenant, not project-wide. Each Ratiba tenant has its own WABA + Twilio number. (For the Phase 1 pilot, n=1 tenant; per-tenant credentials are still the right shape from day one to avoid a multi-tenant retrofit later.)

D5. Dev workflow uses Meta's free test phone number

For all M4 development + CI testing:

  • The single Ratiba dev app has one Meta-provided test phone number (auto-provisioned on App creation, +1 555 area code).
  • The test number can message up to 5 pre-registered recipient phones. Adrian's personal phone is registered.
  • Inbound webhooks fire only when an app admin/developer/tester messages the test number (Meta restricts inbound delivery to authorized testers while the app is unpublished).
  • Outbound from the test number can use freeform text within the 24h conversation window (after the test recipient has messaged the number first). Templates (e.g., hello_world) are subject to additional Meta-side gating that we don't depend on.

D6. Reversibility — the schema preserves the option to switch to a BSP later

The per-tenant whatsapp_phone_number_id + whatsapp_access_token schema works whether the request originates from Meta's CDN directly or via a BSP proxy. If pilot data (latency, reliability, support escalations) reveals a need to switch to a BSP, the tenant-row credentials change format but the IdentityResolver, inquiry routing, and outbound sender code remain valid.

The migration cost of switching, if forced, is one Alembic ALTER to rename / repurpose columns + one rewrite of the outbound sender's HTTP client. The orchestrator doesn't care.

This is a reversible decision. Documenting it as such so future-us isn't paralyzed by the irreversibility framing that often comes with ADRs.

Consequences

Positive

  • Cost savings: ~€600/year vs 360dialog for the project lifetime.
  • Single-secret HMAC simplifies the multi-tenant boundary (one WHATSAPP_APP_SECRET instead of N per-tenant webhook secrets to rotate).
  • Direct Meta payload — no BSP-shape translation layer in Ratiba code.
  • Twilio long-code provisioning is a well-trodden path; Twilio's Self-Signup Guide explicitly supports this flow.
  • Free test number lets dev + CI run against real Meta infrastructure with €0 ongoing cost.

Negative

  • More setup work upfront — BMA + App + WABA + phone-number registration is genuinely several hours the first time. 360dialog would have absorbed this. (Mitigated: the work is one-time per tenant, low-frequency.)
  • No BSP support escalation channel — if Meta-side issues arise, Ratiba reads Meta's docs and files Meta support tickets like every other Cloud API direct user. (Mitigated: Meta's developer community + docs are good.)
  • Twilio adds a dependency for production phone-number provisioning. (Mitigated: Twilio's role ends after WABA verification — they're not in the runtime path. Real SIM cards are an alternative that avoids this dependency entirely.)
  • Meta rate-limit + spam-policy concerns are now Ratiba's directly. A BSP would have buffered some of these. (Mitigated: rate limits apply per WABA; for n=1 pilot tenant the limits are far above expected volume. Spam policy is straightforward — we don't send marketing-initiated conversations in M4-M6 scope.)

Neutral

  • PRD §2.1 + ADR-0001 reference 360dialog — those documents predate this ADR and are now stale on this specific point. Updating PRD wholesale is out of scope; this ADR supersedes the BSP choice.

Alternatives Considered

Twilio as BSP (end-to-end Twilio)

Twilio is itself a registered WhatsApp BSP. Using their REST API (instead of Meta's Graph API) for both inbound and outbound was considered. Rejected because:

  • Adds a markup (~10-30%) on every message vs Meta direct.
  • Different webhook envelope shape than Meta's; a BSP-shape translation layer would have been needed anyway.
  • Vendor lock-in to Twilio's slightly different message-id format and webhook delivery characteristics.

Reopen if: Meta's Cloud API direct ergonomics become genuinely painful (rate-limit support escalation, account-state issues we can't self-service).

Stay with 360dialog

Rejected. The €49/month buys hand-holding (account setup, policy guidance) that an experienced developer working with AI assistance doesn't need. Their offering is explicitly "pure API access with no UI" — equivalent to what Meta provides for free. The migration cost from BSP to Cloud API direct is small (this ADR's D6); the recurring cost of staying on the BSP is high.

Reopen if: an enterprise customer requires a contracted BSP (some compliance regimes prefer it) and is willing to pay for the overhead.

WhatsApp Cloud API + a different number provider (e.g., MessageBird, Vonage)

Considered briefly. Twilio is the dominant verified path with explicit Self-Signup support for WhatsApp Business Platform registration. Other providers may also work but introduce unknown-unknowns; default to the well-trodden path.

Reopen if: Twilio pricing or service quality becomes a bottleneck.

Implementation impact

M4 plan revisions (v1 → v2 needed before walkthrough)

The v1 M4 plan at docs/superpowers/plans/2026-04-26-whatsapp-channel-first-slice.md was written assuming 360dialog. Required changes for v2:

  • Task 1 column names: whatsapp_bsp_*whatsapp_* per D3.
  • Task 1: drop whatsapp_bsp_webhook_secret (per-tenant) — the HMAC key is now project-level (D2).
  • Settings: add whatsapp_app_secret: SecretStr field (required).
  • Task 4 outbound sender: target https://graph.facebook.com/v{N}/{phone_number_id}/messages not waba.360dialog.io/....
  • Task 6 webhook signature verification: use the project-level WHATSAPP_APP_SECRET from Settings, not a per-tenant value.
  • References to "BSP" in docstrings and comments → "Cloud API".

The Task 7 webhook handler is unchanged in shape; only the secret source for HMAC verification changes.

Already landed (M4-prep)

  • app/api/webhooks/whatsapp.py GET-handshake stub (e1da44e) is Cloud-API-direct-shaped: Meta's hub.mode=subscribe validation, Meta's X-Hub-Signature-256 verification (POST handler in M4 Task 7). No 360dialog-specific code anywhere.
  • STATE.md reflects the pivot.

Validated end-to-end on 2026-04-26

The Cloud API direct integration was exercised live during the M4-prep loop, against a Meta-provided free test number (+1 555 916 8695) and Adrian's personal WhatsApp number registered as a test recipient:

  1. cd backend && .venv/bin/uvicorn app.main:app --port 8010 — FastAPI app boots, asyncpg pool initializes against local Postgres.
  2. ngrok http 8010 — public URL https://unenergetic-rhea-intolerant.ngrok-free.dev.
  3. Meta App Dashboard → WhatsApp → Configuration → Callback URL set
    • Verify Token ratiba-dev-webhook-2026 → "Verify and save" succeeds (Meta GETs ?hub.mode=subscribe&hub.challenge=<rand> from IPv6 2a03:2880::*; our stub returns the challenge as plain text; Meta accepts).
  4. messages webhook field subscribed.
  5. WhatsApp message sent from +32486571902 to +1 (555) 916-8695.
  6. Within 1 second, Meta POSTs the webhook to our stub. Real payload confirms the envelope shape ADR-0008 D1 assumes:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "<waba_id>",
"changes": [{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "<test_number_e164_no_plus>",
"phone_number_id": "<phone_number_id>"
},
"contacts": [{
"profile": {"name": "<sender_display_name>"},
"wa_id": "<sender_e164_no_plus>",
"user_id": "<bsuid>"
}],
"messages": [{
"from": "<sender_e164_no_plus>",
"from_user_id": "<bsuid>",
"id": "wamid.<base64ish>",
"timestamp": "<unix_seconds>",
"text": {"body": "<message_text>"},
"type": "text"
}]
},
"field": "messages"
}]
}]
}

Validation findings (both pin items for the M4 plan v2):

  • from_user_id is Meta's new BSUID (Business-Scoped User ID), rolled out in 2026 per Meta's developer docs. It's a stable per-(business, end-user) identifier independent of the user's WhatsApp phone. M4 Task 3 (IdentityResolver) keys on from (E.164) for channel-boundary lookup, but from_user_id is the long-term stable identifier Meta will eventually require for some operations. M4 plan v2 should add a whatsapp_user_id column on contacts (per-tenant) and possibly tenant_admins (public) for forward-compat.
  • phone_number_id lands as a 16-digit string in practice; VARCHAR(50) per D3 is right-sized.
  • The webhook payload's HMAC arrives in the X-Hub-Signature-256 header. M4 Task 7 captures this header verbatim against the raw request body — no payload re-serialization (which would invalidate the signature). The current handshake stub doesn't verify HMAC by design (banner says "DO NOT publish").

References

  • PRD §2.1 — original BSP selection (now superseded by this ADR).
  • ADR-0001 — tech stack pinning (BSP entry to be cross-referenced to this ADR; doc-only update, not a re-litigation).
  • M4 plan v1: docs/superpowers/plans/2026-04-26-whatsapp-channel-first-slice.md (revision to v2 follows acceptance of this ADR).
  • Meta WhatsApp Business Platform pricing: https://developers.facebook.com/docs/whatsapp/pricing
  • Meta Cloud API getting started: https://developers.facebook.com/docs/whatsapp/cloud-api/get-started
  • Twilio Self-Signup Guide for WhatsApp: https://www.twilio.com/docs/whatsapp/self-sign-up

Revision history

  • 2026-04-26: Drafted post-M3 close-out, during M4 Plan Walkthrough phase, after Adrian raised cost-and-control concerns about the BSP layer. Web research validated the Cloud API direct path. Walkthrough surfaced the reversibility framing (D6) — initial draft over-weighted irreversibility.