Platform API: Event webhooks

Receive HMAC-SHA256 signed lifecycle events: onboarding, connection.mode_resolved, disconnect, and coexistence heartbeat events.

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

EventWhen
onboarding.startedEnd-user opens the onboarding URL
onboarding.completedConnection successfully created and Meta webhook subscribed
onboarding.failedCancelled, expired, OAuth exchange failed, WABA conflict, etc.
connection.disconnectedConnection removed via API or by Meta (token revocation, account block)
connection.mode_resolvedA connection's connectionMode changed — typically unknowncoexistence/cloud_api once Meta returns is_on_biz_app. Subscribe to this instead of polling GET /connections/:id.
connection.heartbeat.due_soonCoexistence heartbeat is approaching the 13-day deadline (transition into DUE_SOON)
connection.heartbeat.overdueCoexistence heartbeat exceeded 13 days (transition into OVERDUE)
connection.heartbeat.confirmedA 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; connectionId is the existing connection (no new row was created) and its access token was refreshed.
  • webhookConfigReplaced: true — the tenant rotated its webhook config; the new webhookOverrideUrl + webhookVerifyToken are now live for the whole WABA. When other connections on the WABA adopted the new config, their ids are listed in updatedSiblingConnectionIds.

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":

errorCategoryWhat happenedTenant-facing fix
endpoint_forbiddenTenant's webhook returned HTTP 403 to Meta's verification GETRemove auth on the path; if using n8n, switch from /webhook-test/... to the production /webhook/... URL
endpoint_unauthorizedHTTP 401 — auth middleware blocking the GETAllow unauthenticated GET on the webhook path
endpoint_not_foundHTTP 404 — wrong path or n8n test URL not activeUse a path that responds 200 to GET
endpoint_server_errorHTTP 5xxBring the endpoint back up
endpoint_method_not_allowedHTTP 405 — the path only accepts POSTAdd a GET handler for the verification handshake on the same path
endpoint_timeoutEndpoint didn't respond within ~6s (curl timeout)Return hub.challenge immediately, before any heavy work; check cold starts
challenge_json_wrapper200 OK but body was JSON like {"message":"..."} instead of the raw challengeEcho hub.challenge as plain text, no wrapper
challenge_html_response200 OK but body was HTML (root site, no route bound)Bind a handler to the URL path
challenge_equals_prefix200 OK but body was ={challenge} (literal = prepended)Read hub.challenge from the parsed query string, not the raw URL
challenge_empty_body200 OK with empty bodyWrite hub.challenge to the response body
challenge_response_mismatch200 OK but body was something else (catch-all for 2201)Echo hub.challenge as raw plain text
token_expiredMeta access token issued during signup is no longer validRestart Embedded Signup
permission_errorToken missing required permissionsRestart Embedded Signup and grant scopes
object_not_foundMeta no longer recognizes the WABA (post-offboarding)Restart Embedded Signup
other_meta_errorSome other Meta error — fall back to displaying errorMessageRead 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 (unknowncoexistence/cloud_api) apart from a rare later flip (e.g. coexistencecloud_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.

Related

Browse more docsStart Free Trial