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.
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.
-
Settings UI→Zen API→
GET /api/integrations/elevenlabs?waba_number=…returns the connector (configured,api_key_configuredboolean — never the key) plus a live snapshot of Meta's calling settings fetched viaGET /{phone-number-id}/settings. -
User→Settings 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 (default5061), inbound toggle, optional call-permission template + language. -
Settings UI→Zen API
POST …/connecton first save,PATCH …afterwards (a blank API key keeps the stored one). Backend resolves the project by matching WABA digits against its phone fields. -
Zen API→
If inbound is on, Zen applies WhatsApp Cloud Calling on the number:
POST /{phone-number-id}/settingswith the payload below, then caches it onproject.calling_settings. MOCK-provider projects hit the mock-meta Graph base instead ofgraph.facebook.com. -
Zen API→MongoDB
Upserts
integration_connectors(type: "elevenlabs", generatedel_…username,enabled: true). Re-apply / Disable goes throughPOST …/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.
-
Settings UI→Zen API→→User
{action: "request_permission"}→ Zen sends an interactivecall_permission_requestmessage viaPOST /{phone-number-id}/messages(“We'd like to call you on WhatsApp. Tap to allow.”). The user taps to allow. -
Settings UI→Zen API→ElevenLabs
{action: "call"}→ Zen calls the ElevenLabs SIP trunk with the stored key + agent IDs (see below) and returnsconversation_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.
-
User→
The user taps the call icon on the WhatsApp business number (visibility
DEFAULT, set during connect). -
→ElevenLabs
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. -
→Zen pipeline
In parallel, Meta emits
field=callswebhooks (connect,terminate, …) so Zen still observes the call — see next section.
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.
-
→webhook-ingress
meta_cloud_handlernow acceptsfield: "calls"(previously only"messages") and parses thecalls[]array:id,from/to,event,status,direction,duration,timestamp. -
webhook-ingress→queue
Publishes
webhook_type: "call_event"on the events routing key, deduped asmeta_call_<call-id>_<event>so redeliveries collapse. -
queue→webhook-processor→MongoDB
A new 7th parallel lane (
processCallEventWebhookBatch) upsertscall_sessionskeyed bycall_id: latestevent/status, parties, duration,started_at(orended_atwhenevent: "terminate"), owner + WABA + phone-number IDs. -
webhook-ingress→Customer
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.
| Method | Endpoint | What 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/connect | Validate → enable Meta calling (if inbound) → upsert connector. Requires api_key, agent_id, phone-number ID, SIP host. |
| PATCH | /api/integrations/elevenlabs | Partial 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.
| Store | Content |
|---|---|
| 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_settings | Cached Meta state after each apply: enabled, sip_enabled, sip_hostname/port, updated_at. UI shows the live Meta read, not this cache. |
| call_sessions | One 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. |
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