The architecture in one paragraph
Meta sends override-capable webhook fields directly to the URL configured for each WABA or phone number via Webhook Override. Dualhook applies the WABA-level alternate callback during onboarding using your tenant's webhook endpoint and verify token. Dualhook handles non-overridable management events (template approvals, account alerts, account updates) that keep dashboards and lifecycle events in sync, but stays out of the customer message path entirely.
Per-tenant webhook routing
When you create a Dualhook onboarding session for a tenant, you pass the tenant's webhook URL and verify token on the request. After the tenant completes Embedded Signup, Dualhook applies those values as the WABA-level alternate callback. From that point forward, Meta delivers supported webhook fields for that WABA to the tenant URL instead of Dualhook's app-level callback.
Meta then sends a GET verification challenge to your tenant's endpoint with hub.mode=subscribe, hub.verify_token, and hub.challenge. Your endpoint must compare the verify_token and return the raw hub.challenge as plain text with HTTP 200.
The WABA-level constraint in Dualhook v1
Meta supports both WABA-level and phone-number-level alternate callbacks. Dualhook Platform v1 uses the WABA-level path. That's a Dualhook implementation choice, not a Meta limit, but it means in Dualhook today all phone numbers under one connected WABA share one webhook URL and verify token.
When the same tenant re-onboards onto an already-connected WABA with different webhook values, the new config wins: Dualhook re-subscribes Meta with it and updates every sibling connection on the WABA, so retries, verify-token rotation, and test-to-prod endpoint moves need no cleanup. Only when the WABA already carries another tenant's connections does a mismatch return waba_webhook_conflict — explicit failure beats silently rerouting someone else's numbers.
In practice this is what most partners want: one endpoint per business, demux by phone_number_id from the payload (which you'd do anyway with Cloud API). If you need per-phone-number routing in Dualhook, contact us — it's on the v2 list.
Signature verification
Lifecycle events from Dualhook (onboarding.started/completed/failed, connection.mode_resolved, connection.disconnected, and heartbeat events) come with X-Dualhook-Signature — an HMAC-SHA256 of the raw request body using your Dualhook-issued partner signing secret. Capture and verify the raw bytes before your framework re-parses them, or the HMAC mismatches.
Inbound message-path webhooks from Meta carry X-Hub-Signature-256, but in the Embedded Signup flow it's signed by Dualhook's Meta app and not customer-verifiable. Validate the envelope shape, WABA ID, and phone_number_id per tenant instead, and keep the override URL high-entropy. This is separate from outbound Graph API POSTs, which use the WhatsApp access token for bearer authentication.
Updating webhook URLs (sibling-WABA fan-out)
When a tenant changes their endpoint, PATCH /api/v1/connections/<id> with the new webhookUrl + webhookVerifyToken. Because Dualhook v1 uses one WABA-level subscription per WABA, the change fans out to every sibling connection under the same WABA and returns affectedConnectionIds. Dualhook calls Meta first; if Meta rejects the new endpoint we return webhook_subscribe_failed and leave your DB rows untouched.
Why direct Meta routing matters in a SaaS context
Most WhatsApp BSPs sit in the message path. That means inbound messages travel Meta → BSP → tenant, the BSP becomes a message-storage layer (often with retention defaults and auto-mark-as-read behaviour), and your tenants inherit a compliance surface area they didn't ask for. With Webhook Override configured by Dualhook, message-path traffic goes Meta → tenant directly — Dualhook does not proxy or store the messages.