Dualhook posts HMAC-SHA256 signed lifecycle event webhooks to your configured Event webhook URL (set in the dashboard tab) when interesting things happen on a Platform API connection: an end-user opens an onboarding URL, finishes Embedded Signup, fails it, or has their connection removed. Delivery is durably tracked and retried on non-2xx response.
What lifecycle event webhooks are
These are the events you receive from Dualhook, not the message-path webhooks from Meta. Meta's message-path webhooks go directly to the per-tenant webhookOverrideUrl you set when creating the onboarding session — Dualhook stays out of the message path.
Headers
X-Dualhook-Signature: sha256=<hex>
X-Dualhook-Event: onboarding.completed
X-Dualhook-Event-Id: evt_...
Content-Type: application/json
{ "event": "onboarding.completed", "id": "evt_...", "createdAt": "...", "data": { ... } }
See Webhook signatures for the verification recipe.
Event types
| Event | When |
|---|---|
onboarding.started | End-user opens the onboarding URL |
onboarding.completed | Connection successfully created and Meta webhook subscribed |
onboarding.failed | Cancelled, expired, OAuth exchange failed, WABA conflict, etc. |
connection.disconnected | Connection removed via API or by Meta (token revocation, account block) |
connection.mode_resolved | A connection's connectionMode changed — typically unknown → coexistence/cloud_api once Meta returns is_on_biz_app. Subscribe to this instead of polling GET /connections/:id. |
connection.heartbeat.due_soon | Coexistence heartbeat is approaching the 13-day deadline (transition into DUE_SOON) |
connection.heartbeat.overdue | Coexistence heartbeat exceeded 13 days (transition into OVERDUE) |
connection.heartbeat.confirmed | A heartbeat confirmation was recorded — via dashboard, email link, or POST /heartbeat/confirm |
Sample onboarding.completed payload
Coexistence connection (the common case for partners onboarding existing WhatsApp Business app numbers):
{
"event": "onboarding.completed",
"id": "evt_xxxxxxxx",
"createdAt": "2026-05-01T10:30:00Z",
"data": {
"sessionId": "sess_xxxxxxxx",
"tenantId": "cust_abc123",
"connectionId": "conn_xxx",
"wabaId": "123456789",
"phoneNumberId": "987654321",
"displayPhoneNumber": "+1 555 0123",
"verifiedName": "Acme Co",
"connectionMode": "coexistence",
"coexistenceStatus": "active",
"heartbeatStatus": "OK",
"heartbeatLastConfirmedAt": "2026-05-01T10:30:00Z",
"heartbeatNextDueAt": "2026-05-14T10:30:00Z",
"heartbeatReminderSentAt": null,
"metadata": { "internal_id": "..." }
}
}
For a direct Cloud API onboarding (no WhatsApp Business app), heartbeat fields are null and coexistenceStatus is "not_applicable". If Meta hasn't returned is_on_biz_app yet, connectionMode is "unknown" and coexistenceStatus is "unknown". You don't need to poll for the final value: Dualhook reconciles unknown connections automatically in the background (and on any POST /connections/:id/health/refresh) and fires a connection.mode_resolved event the moment it flips.
When a session re-onboards a tenant that already had connections on the WABA, the payload carries one of two extra flags (see Webhook configuration is WABA-scoped):
alreadyConnected: true— the phone was already connected with identical webhook config;connectionIdis the existing connection (no new row was created) and its access token was refreshed.webhookConfigReplaced: true— the tenant rotated its webhook config; the newwebhookOverrideUrl+webhookVerifyTokenare now live for the whole WABA. When other connections on the WABA adopted the new config, their ids are listed inupdatedSiblingConnectionIds.
Both flags are omitted (not false) on ordinary first-time onboardings, so existing consumers are unaffected.
Sample onboarding.failed payload
Fired when a session terminates without producing a usable connection. Common reasons: tenant cancelled, session expired, OAuth code exchange failed, or — most often — Meta verified your tenant's webhook URL and rejected the response.
{
"event": "onboarding.failed",
"id": "evt_xxxxxxxx",
"createdAt": "2026-05-08T10:30:00Z",
"data": {
"sessionId": "sess_xxxxxxxx",
"tenantId": "cust_abc123",
"wabaId": "123456789",
"phoneNumberId": "987654321",
"displayPhoneNumber": "+1 555 0123",
"verifiedName": "Acme Co",
"reason": "webhook_subscribe_failed",
"errorCode": "webhook_subscribe_failed",
"errorCategory": "endpoint_forbidden",
"errorMessage": "(#2200) Callback verification failed with the following errors: HTTP Status Code = 403; HTTP Message = Forbidden",
"metadata": { "internal_id": "..." }
}
}
errorCode is the high-level reason — webhook_subscribe_failed, cancelled, expired, etc. — and is always present.
errorMessage is Meta's verbatim response when the failure came from a Meta API call. It's there for debugging; don't surface it to your tenant.
errorCategory is a fine-grained classification that's only present when errorCode === "webhook_subscribe_failed". Branch on this in your tenant-facing UI to show a specific fix instead of "subscribe failed":
errorCategory | What happened | Tenant-facing fix |
|---|---|---|
endpoint_forbidden | Tenant's webhook returned HTTP 403 to Meta's verification GET | Remove auth on the path; if using n8n, switch from /webhook-test/... to the production /webhook/... URL |
endpoint_unauthorized | HTTP 401 — auth middleware blocking the GET | Allow unauthenticated GET on the webhook path |
endpoint_not_found | HTTP 404 — wrong path or n8n test URL not active | Use a path that responds 200 to GET |
endpoint_server_error | HTTP 5xx | Bring the endpoint back up |
endpoint_method_not_allowed | HTTP 405 — the path only accepts POST | Add a GET handler for the verification handshake on the same path |
endpoint_timeout | Endpoint didn't respond within ~6s (curl timeout) | Return hub.challenge immediately, before any heavy work; check cold starts |
challenge_json_wrapper | 200 OK but body was JSON like {"message":"..."} instead of the raw challenge | Echo hub.challenge as plain text, no wrapper |
challenge_html_response | 200 OK but body was HTML (root site, no route bound) | Bind a handler to the URL path |
challenge_equals_prefix | 200 OK but body was ={challenge} (literal = prepended) | Read hub.challenge from the parsed query string, not the raw URL |
challenge_empty_body | 200 OK with empty body | Write hub.challenge to the response body |
challenge_response_mismatch | 200 OK but body was something else (catch-all for 2201) | Echo hub.challenge as raw plain text |
token_expired | Meta access token issued during signup is no longer valid | Restart Embedded Signup |
permission_error | Token missing required permissions | Restart Embedded Signup and grant scopes |
object_not_found | Meta no longer recognizes the WABA (post-offboarding) | Restart Embedded Signup |
other_meta_error | Some other Meta error — fall back to displaying errorMessage | Read the error and decide |
errorCategory may be omitted on legacy events; treat its absence as other_meta_error.
Sample connection.mode_resolved payload
A connection can briefly be connectionMode: "unknown" right after signup, until Meta returns is_on_biz_app. Rather than polling GET /connections/:id for the final mode, subscribe to this event — Dualhook resolves unknown connections automatically (a background job re-reads Meta; it also resolves on any POST /connections/:id/health/refresh) and fires connection.mode_resolved as soon as the value changes.
{
"event": "connection.mode_resolved",
"id": "evt_xxxxxxxx",
"createdAt": "2026-05-01T10:31:00Z",
"data": {
"connectionId": "conn_xxx",
"tenantId": "cust_abc123",
"wabaId": "123456789",
"phoneNumberId": "987654321",
"previousConnectionMode": "unknown",
"connectionMode": "coexistence",
"coexistenceStatus": "active",
"heartbeatStatus": "OK",
"heartbeatLastConfirmedAt": "2026-05-01T10:30:00Z",
"heartbeatNextDueAt": "2026-05-14T10:30:00Z",
"metadata": { "internal_id": "..." }
}
}
previousConnectionMode lets you tell the common first-time resolution (unknown → coexistence/cloud_api) apart from a rare later flip (e.g. coexistence → cloud_api if a number is unlinked from the WhatsApp Business app). When the resolved mode is cloud_api, the heartbeat fields are null and coexistenceStatus is "not_applicable". The event fires on any mode change, but only the initial resolution is detected proactively in the background; a later flip is picked up the next time you call POST /connections/:id/health/refresh for that connection.
Heartbeat events (Coexistence)
Coexistence numbers must be opened in the WhatsApp Business app every 13 days, otherwise Meta unlinks them. Dualhook tracks the cycle and notifies you on every transition.
The same payload shape is used for due_soon, overdue, and confirmed:
{
"event": "connection.heartbeat.due_soon",
"id": "evt_xxxxxxxx",
"createdAt": "2026-05-12T10:30:00Z",
"data": {
"connectionId": "conn_xxx",
"tenantId": "cust_abc123",
"wabaId": "123456789",
"phoneNumberId": "987654321",
"heartbeatStatus": "DUE_SOON",
"heartbeatLastConfirmedAt": "2026-05-01T10:30:00Z",
"heartbeatNextDueAt": "2026-05-14T10:30:00Z",
"metadata": { "internal_id": "..." }
}
}
connection.heartbeat.confirmed additionally includes:
{
"data": {
/* ... */
"confirmedVia": "partner_api" // or "dashboard" | "email_link"
}
}
To confirm programmatically (so the customer never sees a Dualhook page), POST to /api/v1/connections/:id/heartbeat/confirm — see connections.
due_soon and overdue only fire on status transitions, so a connection that stays DUE_SOON over multiple cron runs gets exactly one event. Re-firing only happens after a confirmation resets the cycle.
To control whether reminders go to email, webhook, both, or none, use the Heartbeat reminder delivery section in the API & Webhooks dashboard tab. Default for Platform partners with a configured event webhook + signing secret is webhook.
Retry schedule
Retry on non-2xx response: 1m → 5m → 15m → 1h → 6h → 24h, then terminal. Delivery is durably tracked in Dualhook's DB, so even if a worker crashes mid-attempt the cron resender resumes.
When an event goes terminal, Dualhook emails your organization's admins (at most one email per endpoint per 24 hours), and the event becomes redeliverable.
Listing and redelivering events
GET /api/v1/events?status=failed_terminal
Lists your outbound event deliveries, newest first (filters: status = pending | delivered | failed_terminal, eventType; cursor pagination like the other list endpoints). Each item carries eventId, eventType, status, targetUrl, attempts, lastResponseStatus, lastAttemptAt, nextRetryAt, createdAt.
POST /api/v1/events/:id/redeliver
Redrives a terminally failed event: resets its attempt budget, tries once inline, and hands the rest to the retry ladder. Returns { eventId, status, delivered }. Events that are pending (still on the ladder) or delivered (re-sending would duplicate) return 409 event_not_redeliverable. The same table + redeliver button live in the dashboard's API & Webhooks tab.
Redelivery targets your current event webhook URL, not the one stored when the event was created — so "endpoint moved → update the URL → redrive the missed events" works as expected.