WhatsApp Calling with ElevenLabs as the handler

A business connects its own ElevenLabs conversational-AI agent to a WhatsApp number. Meta routes inbound voice calls over SIP to ElevenLabs, outbound calls go through the ElevenLabs SIP trunk, and every call lifecycle event flows back through Zen as webhooks and database rows. Messaging stays on Serri; only voice moves.

0System map

Three planes: the control plane (settings UI → Zen API → Meta) provisions calling; the voice path (green) carries SIP audio between Meta and the ElevenLabs agent; the event pipeline (amber) streams call lifecycle back into MongoDB and customer webhooks.

WhatsApp user caller / callee Settings UI WhatsApp → Calling/IVR Zen API services/api MongoDB connectors · calls Meta Graph API Cloud API + settings ElevenLabs agent + SIP trunk webhook-ingress accepts field=calls webhook-processor call_event lane Customer webhook topic zen.upstream.call connect · trigger connectors · projects POST settings · permission msgs outbound-call calls · permission replies SIP INVITE · SDES/G.711 field=calls webhook call_event (queue) upsert call_sessions forward
Messaging never leaves Serri — only voice is handed to ElevenLabs. Dotted line = customer-facing forward; solid amber = internal queue.
One sentence per plane. Control: the Calling/IVR tab stores BYO ElevenLabs credentials and points Meta at the ElevenLabs SIP server. Voice: Meta sends SIP INVITEs inbound; Zen triggers ElevenLabs SIP-trunk calls outbound. Events: Meta's calls webhooks become call_sessions rows and a zen.upstream.call topic.

1Connect — pair a number with ElevenLabs

Settings → WhatsApp → Calling / IVR tab (new calling tab in whatsapp-integration-tabs, deep-linkable via URL state). Everything is keyed by digits-only waba_number.

  1. Settings UIZen APIMeta

    GET /api/integrations/elevenlabs?waba_number=… returns the connector (configured, api_key_configured boolean — never the key) plus a live snapshot of Meta's calling settings fetched via GET /{phone-number-id}/settings.

  2. UserSettings UI

    Pastes the BYO agent details: ElevenLabs API key (password field, write-only), agent ID, ElevenLabs phone-number ID, SIP hostname (e.g. sip.rtc.elevenlabs.io), port (default 5061), inbound toggle, optional call-permission template + language.

  3. Settings UIZen API

    POST …/connect on first save, PATCH … afterwards (a blank API key keeps the stored one). Backend resolves the project by matching WABA digits against its phone fields.

  4. Zen APIMeta

    If inbound is on, Zen applies WhatsApp Cloud Calling on the number: POST /{phone-number-id}/settings with the payload below, then caches it on project.calling_settings. MOCK-provider projects hit the mock-meta Graph base instead of graph.facebook.com.

  5. Zen APIMongoDB

    Upserts integration_connectors (type: "elevenlabs", generated el_… username, enabled: true). Re-apply / Disable goes through POST …/enable-calling {disable}; DELETE … disables calling on Meta first, then deletes the connector.

// POST /{phone-number-id}/settings — what Zen sends to Meta on connect
{ "calling": {
    "status": "ENABLED",
    "call_icon_visibility": "DEFAULT",
    "callback_permission_status": "ENABLED",
    "srtp_key_exchange_protocol": "SDES",   // ElevenLabs SIP is G.711/SDES,
    "audio": { "additional_codecs": ["PCMA", "PCMU"] }, // not ICE/DTLS/Opus
    "sip": { "status": "ENABLED",
             "webhook_delivery": "ENABLED",
             "servers": [{ "hostname": "sip.rtc.elevenlabs.io", "port": 5061 }] }
} }

2Outbound — permission first, then call

The tab's Test outbound panel drives both steps through one endpoint: POST /api/calls/trigger (auth + idempotency middleware). Numbers are normalized to E.164; the connector must exist and be enabled.

  1. Settings UIZen APIMetaUser

    {action: "request_permission"} → Zen sends an interactive call_permission_request message via POST /{phone-number-id}/messages (“We'd like to call you on WhatsApp. Tap to allow.”). The user taps to allow.

  2. Settings UIZen APIElevenLabs

    {action: "call"} → Zen calls the ElevenLabs SIP trunk with the stored key + agent IDs (see below) and returns conversation_id + sip_call_id. The agent talks to the user; Meta bridges the audio.

// POST https://api.elevenlabs.io/v1/convai/sip-trunk/outbound-call
// Header: xi-api-key: <stored per-number key>
{ "agent_id": "ag_…",
  "agent_phone_number_id": "pn_…",
  "to_number": "+15551212" }
// → { "success": true, "conversation_id": "conv_1", "sip_call_id": "sip_1" }

3Inbound — user calls, agent answers

No Zen code runs in the media path. Provisioning (step 1) already told Meta where to send calls, so inbound is pure SIP between Meta and ElevenLabs.

  1. UserMeta

    The user taps the call icon on the WhatsApp business number (visibility DEFAULT, set during connect).

  2. MetaElevenLabs

    Meta sends a SIP INVITE to the configured server (hostname:port, SDES key exchange, G.711 PCMA/PCMU codecs). ElevenLabs routes it to the BYO agent, which runs the IVR conversation.

  3. MetaZen pipeline

    In parallel, Meta emits field=calls webhooks (connect, terminate, …) so Zen still observes the call — see next section.

Why SDES + G.711? ElevenLabs terminates classic SIP trunks, while WhatsApp defaults to ICE/DTLS with Opus. The connect payload explicitly downgrades the number to SDES + PCMA/PCMU so the two ends can negotiate media.

4Call events — every ring lands in Zen

Both directions (inbound USER_INITIATED and outbound) report lifecycle through the same pipe: Meta → ingress → queue → processor → Mongo + customer webhook.

  1. Metawebhook-ingress

    meta_cloud_handler now accepts field: "calls" (previously only "messages") and parses the calls[] array: id, from/to, event, status, direction, duration, timestamp.

  2. webhook-ingressqueue

    Publishes webhook_type: "call_event" on the events routing key, deduped as meta_call_<call-id>_<event> so redeliveries collapse.

  3. queuewebhook-processorMongoDB

    A new 7th parallel lane (processCallEventWebhookBatch) upserts call_sessions keyed by call_id: latest event/status, parties, duration, started_at (or ended_at when event: "terminate"), owner + WABA + phone-number IDs.

  4. webhook-ingressCustomer

    Ingress also forwards to subscribed customer webhooks under the new topic zen.upstream.call (“WhatsApp Calling lifecycle events … for inbound and outbound voice calls”), listed in the topics catalog.

// call_sessions — one row per call, upserted on every event (migration 0060:
// unique index on call_id; index on project_owner_id + created_at)
{ "call_id": "wacid.abc", "direction": "USER_INITIATED",
  "event": "connect", "status": "CONNECTED",
  "from": "15551212", "to": "PN1", "duration_seconds": 0,
  "started_at": "…", "ended_at": "… (on terminate)" }

5API surface

All routes require auth. The UI calls them via elevenlabs-api.ts with React Query (["elevenlabs-calling", waba]); every response re-attaches the live Meta calling snapshot.

MethodEndpointWhat it does
GET/api/integrations/elevenlabs?waba_number=…Status: configured?, key present?, agent/SIP fields, live whatsapp_calling block. Key never returned.
POST/api/integrations/elevenlabs/connectValidate → enable Meta calling (if inbound) → upsert connector. Requires api_key, agent_id, phone-number ID, SIP host.
PATCH/api/integrations/elevenlabsPartial update; only non-blank key overwrites; re-applies Meta settings when inbound flag or SIP host changes.
POST/api/integrations/elevenlabs/enable-calling{disable} → enable/disable Meta calling now; flips el_inbound_enabled. Powers Re-apply + Disable buttons.
DEL/api/integrations/elevenlabs?waba_number=…Disable Meta calling (best-effort), delete connector. Returns unconfigured status.
POST/api/calls/trigger{waba_number, to, action}request_permission sends the Meta interactive message; call places the ElevenLabs SIP call.

6Data & security notes

Three storage touchpoints, all per-number.

StoreContent
integration_connectors
(type="elevenlabs")
BYO credentials: el_api_key (json:"-" — write-only, GET only reports api_key_configured), el_agent_id, el_phone_number_id, el_sip_hostname/port, el_inbound_enabled, permission-template name/language.
projects.calling_settingsCached Meta state after each apply: enabled, sip_enabled, sip_hostname/port, updated_at. UI shows the live Meta read, not this cache.
call_sessionsOne row per call (call_id unique), latest event/status, parties, duration, start/end times, owner/WABA/phone IDs. Indexes via migration 0060_call_sessions_indexes.
MOCK parity. Projects on the MOCK provider resolve the Graph base to mock-meta, so connect / permission / calling-settings can be exercised without touching Meta — the PR test plan relies on this.

7What each PR touches

File inventory straight from the diffs, so you can jump to the code behind any box above.

backend-zen#1600 — add WhatsApp Calling SIP for ElevenLabs IVR · 25 files
  • services/api/controller/integration/elevenlabs.go (+tests) — connector CRUD + enable-calling
  • services/api/controller/calls/trigger.go — POST /api/calls/trigger
  • services/api/utils/meta_apis.go (+tests) — Enable/DisableCalling, GetPhoneSettings, SendCallPermissionRequest
  • services/api/router/integration/integration.go, router/campaign/campaign.go — route wiring
  • packages/elevenlabsapi/client.go (+tests) — ConvAI SIP-trunk HTTP client
  • packages/models/call_session.go, integration_connector.go, project.go, webhook_delivery_log.go
  • services/webhook-ingress/.../meta_cloud_handler.go (+tests), utils/meta_cloud_publisher.go
  • services/webhook-processor/.../meta_calls_handler.go (+tests), datagen_audience_handlers.go
  • services/api/migrations/scripts/0060_call_sessions_indexes.go
  • packages/contracts/events/queues.go, controller/webhook/webhook_topics.go, go.work, go.mod
zen-frontend#581 — feat: add Calling/IVR settings for ElevenLabs · 5 files
  • whatsapp/components/calling-ivr-content.tsx — the Calling/IVR tab (status card, form, test outbound)
  • whatsapp/api/elevenlabs-api.ts — typed client for all six endpoints
  • whatsapp/components/whatsapp-integration-tabs.tsx — registers the "calling" tab
  • whatsapp/store/whats-app-store.tsx + settings/url-state.ts — tab type + deep-link parsing