Platform API: Connections

List, get, update, and disconnect WhatsApp connections programmatically. Includes connectionMode, coexistence status, heartbeat fields, WABA-scoped webhook fan-out, and sibling-aware disconnect.

A Dualhook connection represents one tenant's WhatsApp phone number after Embedded Signup completes. Connections expose list / get / update / disconnect endpoints under /api/v1/connections. Webhook updates fan out across siblings under the same WABA (Dualhook Platform v1 uses one WABA-level Webhook Override subscription per connected WABA), and disconnect is sibling-aware so removing one number doesn't break the others.

What a connection is

One row per (wabaId, phoneNumberId) connected through your API key. Connections carry: status, phone number, WABA id, the webhook URL on file, the latest health snapshot, and your tenantId. Access tokens are stored encrypted; you fetch them on demand via reveal-secrets.

List

GET /api/v1/connections?tenantId=cust_abc123&limit=50

Paginated; pass back nextCursor to fetch the next page. Filter by tenantId. Default page size is server-defined; the response includes nextCursor: null on the last page.

Get metadata

GET /api/v1/connections/:id

Never returns secrets. Includes status, phone number, WABA id, webhook URL, connection mode, Coexistence sync timestamps, and the latest heartbeat state. The GET /api/v1/connections list endpoint and GET /api/v1/tenants/:externalTenantId return the same shape for each connection.

{
  "connectionId": "conn_xxx",
  "name": "Acme WhatsApp",
  "wabaId": "1253...",
  "phoneNumberId": "987...",
  "webhookUrl": "https://api.example.com/whatsapp",
  "status": "active",
  "tenantId": "cust_abc123",
  "metadata": { /* round-tripped from session */ },
  "connectionMode": "coexistence",
  "coexistenceStatus": "active",
  "historySyncTriggeredAt": "2026-05-01T10:30:03Z",
  "contactSyncTriggeredAt": "2026-05-01T10:30:04Z",
  "heartbeatApplies": true,
  "heartbeatStatus": "OK",
  "heartbeatLastConfirmedAt": "2026-05-01T10:30:00Z",
  "heartbeatNextDueAt": "2026-05-14T10:30:00Z",
  "heartbeatReminderSentAt": null,
  /* createdAt, updatedAt, billingSuspendedAt, ... */
}

connectionMode is one of:

  • "coexistence" — number is on both Cloud API and the WhatsApp Business app. The 13-day heartbeat applies.
  • "cloud_api" — direct Cloud API only. Heartbeat does not apply; all heartbeat fields are null.
  • "unknown" — Meta didn't return is_on_biz_app yet. Tracked conservatively (heartbeat applies).

coexistenceStatus is a partner-friendly string: "active", "not_applicable", or "unknown". heartbeatApplies is a boolean shortcut equivalent to connectionMode !== "cloud_api".

For Coexistence connections, Dualhook requests Meta's one-time history and contact sync right after Webhook Override is accepted. historySyncTriggeredAt and contactSyncTriggeredAt record when Dualhook made those trigger calls and Meta returned an accepted or benign result; they do not guarantee that the business shared history or that Meta has delivered every chunk to your webhook.

You don't need to poll for an "unknown" connection to resolve. Dualhook reconciles unknown connections automatically in the background (and on any POST /connections/:id/health/refresh) and fires a connection.mode_resolved lifecycle event the moment the mode flips — subscribe to that instead.

Update webhook URL (WABA-scoped fan-out)

PATCH /api/v1/connections/:id
Content-Type: application/json

{
  "webhookUrl": "https://api.example.com/new",
  "webhookVerifyToken": "<your endpoint's hub.verify_token>"
}

Both fields are required. Because Dualhook Platform v1 uses one WABA-level Webhook Override subscription per connected WABA, this fans out to every sibling connection under the same WABA. The response includes affectedConnectionIds[]:

{
  "connectionId": "conn_xxx",
  "wabaId": "1253...",
  "webhookUrl": "https://api.example.com/new",
  "affectedConnectionIds": ["conn_xxx", "conn_yyy", "conn_zzz"]
}

We call Meta first; if Meta rejects the new endpoint we return 422 webhook_subscribe_failed and leave your DB rows untouched.

PATCH /api/v1/wabas/:wabaId/webhook is the same handler keyed by WABA id.

You usually don't need this endpoint just to recover a tenant's onboarding: re-running an onboarding session for the same tenant with new webhook values performs the same replace-and-fan-out automatically (and refreshes the Meta access token at the same time). Reach for the PATCH when you want to rotate webhook config without sending the tenant through Embedded Signup again.

Disconnect

DELETE /api/v1/connections/:id

Removes the connection. Sibling-aware: Meta's DELETE /{wabaId}/subscribed_apps is only called when this is the last connection under that WABA — siblings stay subscribed. Fires connection.disconnected with reason='partner_initiated'.

Confirm Coexistence heartbeat

POST /api/v1/connections/:id/heartbeat/confirm

Programmatically reset the 13-day Coexistence heartbeat for a connection. Use when your end-customer clicks "I opened WhatsApp Business app" inside your product, so they never see a Dualhook page. Empty body.

{
  "connectionId": "conn_xxx",
  "heartbeatStatus": "OK",
  "heartbeatLastConfirmedAt": "2026-05-04T12:00:00Z",
  "heartbeatNextDueAt": "2026-05-17T12:00:00Z"
}

A successful confirmation fires connection.heartbeat.confirmed with confirmedVia: "partner_api".

Idempotency. Pass an Idempotency-Key header to make retries safe — repeat requests within 1 hour return the cached response and emit no second event. Even without the header, the helper debounces by recency: a second confirm within 60 seconds is a no-op.

Returns 409 when the connection's connectionMode === "cloud_api" — heartbeat doesn't apply to direct Cloud API numbers. Inspect coexistenceStatus from the GET endpoint before calling, or just be ready to handle 409 as "no action needed."

Health

GET  /api/v1/connections/:id/health
POST /api/v1/connections/:id/health/refresh

GET returns the latest stored snapshot:

{
  "connectionId": "conn_xxx",
  "health": {
    "healthStatus": "AVAILABLE",
    "qualityRating": "GREEN",
    "accountMode": null,
    "messagingTier": "TIER_250",
    "nameStatus": "APPROVED",
    "newNameStatus": null,
    "additionalInfo": null,
    "errorMessage": null,
    "checkedAt": "2026-05-02T10:30:00Z"
  }
}

health is null if no snapshot exists yet — call refresh first.

POST /health/refresh forces a fresh fetch from Meta (rate-limited to 10/min per key) and returns an acknowledgement only:

{ "connectionId": "conn_xxx", "refreshed": true }

Read the new snapshot via the GET endpoint.

Related

Browse more docsStart Free Trial