Replace the WhatsappCall model with the unified Call model introduced in feat/voice-call-model (PR #14026), enabling multi-provider support (Twilio + WhatsApp) under a single data model. Key changes: - Delete WhatsappCall model and its 3 migrations (whatsapp_calls table) - Enhance Call model with WhatsApp-specific helpers (sdp_offer, ice_servers, recording_url, direction_label, ringing?, in_progress?) - Update all backend services, controller, and jobs to use Call model - Map statuses: accepted→in_progress, rejected→failed, missed→no_answer, ended→completed - Map directions: inbound→incoming, outbound→outgoing (enums) - Rename call_id column to provider_call_id, always set provider: :whatsapp - Add authorization check on initiate action - Add race condition guard in outbound call_connect webhook handler - Consolidate direction_label helper into Call model (DRY) - Update frontend data key from waCallId to callId in VoiceCall bubble - Remove whatsapp_calls table from schema.rb, fix missing started_at Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
70 KiB
WhatsApp Calling in Chatwoot
WhatsApp Calling lets agents receive and make voice calls directly from the Chatwoot dashboard using the WhatsApp Cloud API. Calls are browser-based (WebRTC) — no phone hardware or Twilio required.
Enterprise only. Gated behind the
whatsapp_callfeature flag.
Table of Contents
- How WhatsApp Calling Works (The Big Picture)
- Setup Guide
- Product Walkthrough
- Technical Architecture
- Call Flows (Step by Step)
- Call Recording & Transcription
- Data Model
- API Reference
- WebSocket Events
- Key Files
- Challenges & Design Decisions
- Extending the Feature
1. How WhatsApp Calling Works (The Big Picture)
Before diving into code, here's the mental model for the entire feature.
Three players, two communication channels
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1. SIGNALING (who's calling whom, accept/reject, hang up) │
│ Travels through: Meta API <-> Chatwoot Backend <-> Browser │
│ │
│ 2. AUDIO (the actual voice data) │
│ Travels through: Meta Servers <-----------> Browser │
│ (Chatwoot backend is NOT in this path) │
│ │
└─────────────────────────────────────────────────────────────────┘
Think of it like a phone operator (signaling) connecting two people (audio). The operator sets up the call but doesn't listen in — the audio flows directly between the two parties.
┌──────────────┐ ┌──────────────────┐
│ WhatsApp │ Signaling (REST/Webhooks) │ Chatwoot │
│ Contact │◄─────────────────────────────────────►│ Backend │
│ │ │ (Rails) │
└──────┬───────┘ └────────┬─────────┘
│ │
│ WebSocket │ (ActionCable)
│ │
│ ┌────────────────────────┐ ┌────────▼─────────┐
│ │ Meta Media Servers │ │ Agent's │
│ │ │ │ Browser │
└────────►│ (routes the audio │◄══════════►│ (Vue + WebRTC) │
│ between endpoints) │ Audio │ │
└────────────────────────┘ (SRTP) └──────────────────┘
Why does Chatwoot's backend not touch the audio?
The audio is a peer-to-peer WebRTC connection between the agent's browser and Meta's media servers. This is intentional — it reduces latency, avoids the need for media proxies, and is how Meta designed their calling API. The browser has direct access to both audio tracks (local mic + remote caller), which is what makes client-side call recording possible.
What is WebRTC?
WebRTC (Web Real-Time Communication) is a browser API that enables audio/video communication without plugins. The key concepts:
| Concept | What it means |
|---|---|
| RTCPeerConnection | The browser object that manages the audio connection |
| SDP (Session Description Protocol) | A text blob describing what media you can send/receive, your IP address, ports, codecs, etc. Think of it as a "business card" for the call |
| SDP Offer | "Here's what I can do" — sent by the caller |
| SDP Answer | "Here's what I can do too" — sent by the receiver |
| ICE (Interactive Connectivity Establishment) | The process of discovering how two machines can reach each other through NATs/firewalls |
| STUN server | Helps you discover your public IP address |
| SRTP | Encrypted audio packets that flow once the connection is established |
The SDP handshake (simplified)
Caller (Meta) Receiver (Agent Browser)
│ │
│ "I want to call you. Here's my SDP offer │
│ (my IP, ports, codecs I support)" │
│───────────────────────────────────────────────────────►│
│ │
│ "Got it. Here's my SDP answer │
│ (my IP, ports, codecs I support)" │
│◄───────────────────────────────────────────────────────│
│ │
│ │
│◄═══════════════ Audio flows both ways ════════════════►│
2. Setup Guide
Prerequisites
| Requirement | Details |
|---|---|
| Chatwoot Enterprise | Feature flag whatsapp_call enabled for the account |
| WhatsApp Cloud API inbox | Provider must be whatsapp_cloud |
| Meta Business account | With a verified WhatsApp Business phone number |
calls webhook field |
Must be subscribed on the WABA (not included by default) |
| Browser microphone | Agents must grant mic permission when prompted |
Step-by-step
1. Enable the feature flag
account = Account.find(<id>)
account.enable_features('whatsapp_call')
account.save!
2. Enable calling on the inbox
inbox = Inbox.find(<id>)
inbox.channel.provider_config['calling_enabled'] = true
inbox.channel.save!
3. Subscribe to the calls webhook field
By default, Chatwoot subscribes to messages and smb_message_echoes. The calls field must be added.
Via Graph API:
curl -X POST \
"https://graph.facebook.com/v22.0/<WABA_ID>/subscribed_apps" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-d "subscribed_fields=messages,smb_message_echoes,calls"
Via Meta App Dashboard: WhatsApp > Configuration > Webhooks > enable calls field.
4. Verify the webhook endpoint
https://<YOUR_CHATWOOT_URL>/webhooks/whatsapp/<PHONE_NUMBER>
5. For call transcription (optional)
Requires captain_integration feature flag + OpenAI API key:
account.enable_features('captain_integration')
account.save!
# Ensure this is configured:
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c|
c.value = 'sk-...'
end
3. Product Walkthrough
Receiving a call (Inbound)
┌──────────────────────────────────────────────────────────────────────┐
│ What the agent sees │
│ │
│ 1. Floating widget appears (bottom-right) │
│ ┌─────────────────────────────────────┐ │
│ │ 📞 John Doe │ │
│ │ Incoming WhatsApp Call │ │
│ │ [Reject] [Accept] │ │
│ └─────────────────────────────────────┘ │
│ │
│ 2. Message bubble in conversation │
│ ┌─────────────────────────┐ │
│ │ 📞 Incoming call │ │
│ │ Not answered yet │ │
│ │ [Accept] │ │
│ └─────────────────────────┘ │
│ │
│ 3. After accepting │
│ ┌─────────────────────────────────────┐ │
│ │ 📞 John Doe [🔇] [📕] │ │
│ │ 02:34 │ │
│ └─────────────────────────────────────┘ │
│ │
│ 4. After call ends │
│ ┌─────────────────────────┐ │
│ │ 📞 Call ended │ │
│ │ Answered by John │ │
│ │ 2m 34s │ │
│ │ ▶ ───●─────── 2:34 │ ← audio recording │
│ │ ▸ Transcript │ ← expandable transcript │
│ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Making a call (Outbound)
- Agent clicks the phone icon in the conversation header.
- Browser requests microphone permission.
- A "Calling..." toast appears; the widget shows "Ringing...".
- When the contact answers, audio flows and the timer starts.
- Agent hangs up from the widget.
Call permission flow
Meta requires contacts to explicitly opt-in to receive calls from a business. If the contact hasn't granted permission:
- Agent clicks call → Chatwoot sends a permission request (interactive message in WhatsApp chat).
- Agent sees: "Permission requested — the contact will receive a prompt."
- Contact approves in WhatsApp → agent gets notified and can retry the call.
- Rate-limited to one request per 5 minutes per conversation.
Limitations
- Audio-only — no video support.
- One active call per agent at a time.
- Requires the WhatsApp Cloud API provider (not on-premise API / 360dialog).
- Ringing calls survive refresh — the agent can accept from the message bubble after reloading. The SDP offer is stored on the server.
- Active calls do NOT survive refresh — WhatsApp calls are peer-to-peer WebRTC. If the agent refreshes or closes the tab during an active call, the call is automatically terminated and the recording is lost. (See Page refresh / close for full details.)
- Unanswered inbound calls auto-dismiss from the widget after 30 seconds.
- Recording depends on clean call end — if the browser crashes or the page is closed during a call, the in-memory recording is lost. Only calls that end normally (hang up button or caller disconnect) produce recordings.
4. Technical Architecture
Technology stack
| Layer | Technology | Role |
|---|---|---|
| Signaling | Meta WhatsApp Cloud API (/{phone_id}/calls) |
Call setup, accept, reject, terminate |
| Real-time events | ActionCable (WebSocket) | Push call events to all agents in the account |
| Media transport | WebRTC (RTCPeerConnection) |
Browser ↔ Meta peer-to-peer audio |
| Call UI | Vue 3 floating widget + VoiceCall.vue bubble | Agent-facing call controls |
| State | Pinia (whatsappCalls.js) + Vuex (messages) |
Frontend call + message state |
| Backend | Rails controllers + services (enterprise) | API endpoints, webhook processing |
| Background jobs | Sidekiq | Webhook processing, transcription |
| Recording | Browser MediaRecorder API |
Client-side call recording |
| Transcription | OpenAI Whisper (whisper-1) |
Post-call speech-to-text |
How WhatsApp calls fit into the existing Voice/Twilio pattern
WhatsApp calls use the same voice_call content_type as Twilio calls. This means:
┌────────────────────────────┐
│ voice_call message │
│ content_type: voice_call │
└──────────────┬─────────────┘
│
┌──────────────┴─────────────┐
│ │
call_source: whatsapp call_source: (absent)
│ │
┌───────▼────────┐ ┌───────▼────────┐
│ WhatsApp Calls │ │ Twilio Calls │
│ Store (Pinia) │ │ Store (Pinia) │
│ Peer-to-peer │ │ Conference │
└────────────────┘ └────────────────┘
The voice.js helper inspects call_source and routes events to the correct store. The VoiceCall.vue bubble renders both — with minor behavior differences (e.g., WhatsApp calls only show "Accept" while ringing, Twilio also shows "Join" for in-progress calls).
Status mapping
WhatsApp call statuses are mapped to Voice statuses for UI compatibility:
WhatsApp Backend Message (content_attributes) UI Display
───────────────── ──────────────────────────── ──────────────────
ringing ──► ringing ──► "Incoming call"
accepted ──► in-progress ──► "Call in progress"
rejected ──► failed ──► "Missed call"
missed ──► no-answer ──► "Missed call"
ended ──► completed ──► "Call ended"
failed ──► failed ──► "Missed call"
Enterprise architecture
All calling logic lives under enterprise/:
enterprise/
├── app/
│ ├── controllers/api/v1/accounts/
│ │ └── whatsapp_calls_controller.rb ← REST API
│ ├── models/
│ │ └── whatsapp_call.rb ← Data model + ActiveStorage recording
│ ├── services/whatsapp/
│ │ ├── call_message_builder.rb ← Creates/updates voice_call messages
│ │ ├── call_service.rb ← Accept/reject/terminate with Meta
│ │ ├── call_transcription_service.rb ← Whisper transcription
│ │ ├── incoming_call_service.rb ← Webhook → call record + message
│ │ ├── call_permission_reply_service.rb
│ │ └── providers/
│ │ └── whatsapp_cloud_call_methods.rb ← Meta Graph API calls
│ └── jobs/
│ ├── enterprise/webhooks/
│ │ └── whatsapp_events_job.rb ← Routes call webhooks
│ └── whatsapp/
│ └── call_transcription_job.rb ← Async transcription
The OSS Webhooks::WhatsappEventsJob is extended via prepend_mod_with to detect and route call events to the enterprise services.
5. Call Flows (Step by Step)
Inbound call
WhatsApp Chatwoot Agent
Contact Meta Cloud Backend Browser
│ │ │ │
│ │ │ │
1. │── Dials ────────►│ │ │
│ business # │ │ │
│ │ │ │
2. │ │── Webhook ──────────►│ │
│ │ event: connect │ │
│ │ payload: { │ │
│ │ id, from, │ │
│ │ sdp_offer │ │
│ │ } │ │
│ │ │ │
3. │ │ ┌─────┴──────────┐ │
│ │ │ IncomingCall │ │
│ │ │ Service: │ │
│ │ │ │ │
│ │ │ a. Find/create │ │
│ │ │ Contact │ │
│ │ │ b. Find/create │ │
│ │ │ Conversation │ │
│ │ │ c. Create │ │
│ │ │ WhatsappCall │ │
│ │ │ (ringing) │ │
│ │ │ d. Create │ │
│ │ │ voice_call │ │
│ │ │ message │ │
│ │ └─────┬──────────┘ │
│ │ │ │
4. │ │ │── ActionCable ──────────►│
│ │ │ whatsapp_call.incoming │
│ │ │ {sdp_offer, caller, │
│ │ │ ice_servers} │
│ │ │ │
5. │ │ │ ┌──────┴───────┐
│ │ │ │ Show widget │
│ │ │ │ Show bubble │
│ │ │ │ with [Accept] │
│ │ │ └──────┬───────┘
│ │ │ │
│ │ │ Agent clicks │
│ │ │ "Accept" │
│ │ │ │
6. │ │ │ ┌──────┴───────┐
│ │ │ │ getUserMedia │
│ │ │ │ (mic access) │
│ │ │ │ │
│ │ │ │ new RTC │
│ │ │ │ PeerConnection│
│ │ │ │ │
│ │ │ │ setRemoteDesc │
│ │ │ │ (Meta's SDP │
│ │ │ │ offer) │
│ │ │ │ │
│ │ │ │ createAnswer │
│ │ │ │ Wait for ICE │
│ │ │ │ gathering │
│ │ │ └──────┬───────┘
│ │ │ │
7. │ │ │◄── POST /accept ────────│
│ │ │ {sdp_answer} │
│ │ │ │
8. │ │ ┌─────┴──────────┐ │
│ │ │ CallService │ │
│ │ │ (with row lock) │ │
│ │ │ │ │
│ │ │ a. Validate │ │
│ │ │ still ringing│ │
│ │ │ b. Fix SDP │ │
│ │ │ (actpass → │ │
│ │ │ active) │ │
│ │ └─────┬──────────┘ │
│ │ │ │
9. │ │◄── pre_accept ──────│ │
│ │◄── accept ──────────│ │
│ │ │ │
10. │ │ ┌─────┴──────────┐ │
│ │ │ Update call → │ │
│ │ │ accepted │ │
│ │ │ Update msg → │ │
│ │ │ in-progress │ │
│ │ │ + answered_by │ │
│ │ └─────┬──────────┘ │
│ │ │ │
11. │ │ │── ActionCable ──────────►│
│ │ │ whatsapp_call.accepted │
│ │ │ │
│ │ │ Other agents: │
│ │ │ call removed │
│ │ │ from their widget │
│ │ │ │
12. │◄════════════════ WebRTC Audio (SRTP, peer-to-peer) ════════════►│
│ │ │ │
│ │ │ ┌──────┴───────┐
│ │ │ │ MediaRecorder │
│ │ │ │ starts │
│ │ │ │ recording │
│ │ │ │ both tracks │
│ │ │ └──────┬───────┘
│ │ │ │
13. │── Hangs up ─────►│ │ │
│ │── Webhook ──────────►│ │
│ │ event: terminate │ │
│ │ {duration, reason} │ │
│ │ │ │
14. │ │ ┌─────┴──────────┐ │
│ │ │ Update call → │ │
│ │ │ ended │ │
│ │ │ Update msg → │ │
│ │ │ completed │ │
│ │ │ + duration │ │
│ │ └─────┬──────────┘ │
│ │ │ │
15. │ │ │── ActionCable ──────────►│
│ │ │ whatsapp_call.ended │
│ │ │ ┌──────┴───────┐
│ │ │ │ Stop recorder │
│ │ │ │ Upload .webm │
│ │ │ │ Cleanup WebRTC│
│ │ │ └──────┬───────┘
│ │ │ │
16. │ │ │◄── POST /upload ────────│
│ │ │ _recording │
│ │ ┌─────┴──────────┐ │
│ │ │ Attach to │ │
│ │ │ ActiveStorage │ │
│ │ │ │ │
│ │ │ Enqueue │ │
│ │ │ transcription │ │
│ │ │ job │ │
│ │ └─────┬──────────┘ │
│ │ │ │
17. │ │ ┌─────┴──────────┐ │
│ │ │ Whisper API │ │
│ │ │ transcribes │ │
│ │ │ │ │
│ │ │ Update msg with │ │
│ │ │ recording_url + │ │
│ │ │ transcript │ │
│ │ └────────────────┘ │
Outbound call
Agent Chatwoot Meta WhatsApp
Browser Backend Cloud Contact
│ │ │ │
1. │── Click phone icon │ │ │
│ │ │ │
2. │ getUserMedia (mic) │ │ │
│ new RTCPeerConnection │ │ │
│ createOffer + ICE │ │ │
│ │ │ │
3. │── POST /initiate ─────►│ │ │
│ {conversation_id, │ │ │
│ sdp_offer} │ │ │
│ │── initiate_call ──────►│ │
│ │ {to, sdp_offer} │── Rings phone ──────►│
│ │ │ │
│ │◄── {call_id} ──────────│ │
│ │ │ │
4. │ ┌─────┴──────────┐ │ │
│ │ Create │ │ │
│ │ WhatsappCall │ │ │
│ │ (outbound, │ │ │
│ │ ringing) │ │ │
│ │ Create │ │ │
│ │ voice_call msg │ │ │
│ └─────┬──────────┘ │ │
│ │ │ │
5. │◄── {call_id, id} ─────│ │ │
│ │ │ │
│ Widget: "Ringing..." │ │ │
│ │ │ │
│ │ │ Contact answers │
│ │ │◄─────────────────────│
│ │ │ │
6. │ │◄── Webhook ────────────│ │
│ │ event: connect │ │
│ │ {call_id, sdp_answer} │ │
│ │ │ │
7. │ ┌─────┴──────────┐ │ │
│ │ Find existing │ │ │
│ │ call by call_id │ │ │
│ │ Update → │ │ │
│ │ accepted │ │ │
│ └─────┬──────────┘ │ │
│ │ │ │
8. │◄── ActionCable ────────│ │ │
│ whatsapp_call. │ │ │
│ outbound_connected │ │ │
│ {sdp_answer} │ │ │
│ │ │ │
9. │ setRemoteDescription │ │ │
│ (sdp_answer) │ │ │
│ ontrack → play audio │ │ │
│ Start recording │ │ │
│ Timer starts │ │ │
│ │ │ │
10. │◄══════════════ WebRTC Audio (SRTP) ════════════════════════════════════►│
│ │ │ │
│ (same terminate flow as inbound — steps 13-17 above) │
Permission flow
Agent clicks call
│
▼
POST /initiate ─────► Meta returns error 138006 (no permission)
│
▼
Backend sends call_permission_request template to contact via Meta
│
▼
Returns {status: "permission_requested"} ──► Agent sees toast
│
┈┈┈┈┈┈┈┈┈┈ (contact sees permission prompt in WhatsApp) ┈┈┈┈┈┈┈┈┈┈
│
Contact approves ──► Meta webhook (interactive/call_permission_reply)
│
▼
ActionCable: whatsapp_call.permission_granted ──► Agent sees toast
│
▼
Agent can now call (retry)
Page refresh / close — what happens to the call?
This is one of the trickiest parts of the feature. The behavior is different depending on the call state:
Scenario A: Call is RINGING (not yet accepted)
Page refresh does not kill the call. The agent can still accept it after the page reloads.
┌──────────────────────────────────────────────────────────────────────┐
│ RINGING CALL + PAGE REFRESH │
│ │
│ 1. Call comes in → widget + bubble appear │
│ 2. Agent refreshes the page │
│ - In-memory state (Pinia store, SDP offer) is LOST │
│ - The WhatsappCall record on the server is still "ringing" │
│ - The RTCPeerConnection never existed yet (no WebRTC to break) │
│ 3. Page reloads → conversation loads → voice_call message renders │
│ - VoiceCall.vue sees status = "ringing" → shows [Accept] button │
│ 4. Agent clicks [Accept] │
│ - acceptWhatsappCallById() runs │
│ - Call NOT in Pinia store → falls back to API fetch: │
│ GET /whatsapp_calls/:id → returns sdp_offer + ice_servers │
│ - Creates new RTCPeerConnection with the fetched SDP │
│ - Normal accept flow continues │
│ │
│ ✅ Call survives the refresh │
└──────────────────────────────────────────────────────────────────────┘
Agent Browser Backend Meta
│ │ │
│── Page refresh ──► │ │
│ (all JS state lost) │ WhatsappCall still "ringing" │
│ │ │
│── Page loads ──► │ │
│ Conversation renders │ │
│ VoiceCall bubble: [Accept] │ │
│ │ │
│── Click Accept │ │
│ │ │
│── GET /whatsapp_calls/:id ──────►│ │
│◄── {sdp_offer, ice_servers} ─────│ │
│ │ │
│── getUserMedia + WebRTC setup │ │
│── POST /accept {sdp_answer} ────►│── pre_accept + accept ─────►│
│ │ │
│◄══════════════ Audio connected ═══════════════════════════════►│
Why this works: The SDP offer is stored in the WhatsappCall.meta column on the server. The show endpoint returns it for ringing calls. The browser creates a fresh RTCPeerConnection with the server-stored SDP.
Why this only works for ringing calls: Once a call is accepted, the SDP handshake is complete and audio is flowing through a specific RTCPeerConnection instance. That instance lives in browser memory — it cannot be reconstructed. This is a fundamental WebRTC limitation (peer-to-peer, no server-side media relay).
Scenario B: Call is IN PROGRESS (already accepted) — Page close or refresh
Page close/refresh terminates the call. This is intentional and unavoidable.
┌──────────────────────────────────────────────────────────────────────┐
│ ACTIVE CALL + PAGE CLOSE/REFRESH │
│ │
│ 1. Agent is on an active call (audio flowing) │
│ 2. Agent closes tab / refreshes / navigates away │
│ │
│ browser fires "beforeunload" event │
│ │ │
│ ├──► fetch("/terminate", {keepalive: true}) │
│ │ - Uses fetch API (not axios) because axios │
│ │ requests are cancelled on page unload │
│ │ - keepalive: true tells the browser to │
│ │ complete the request even after the page dies │
│ │ - Auth headers read from session cookie │
│ │ - Fire-and-forget (no await, .catch(() => {})) │
│ │ │
│ └──► cleanupInboundWebRTC() │
│ - Stops all mic tracks │
│ - Closes RTCPeerConnection │
│ - Removes <audio> element from DOM │
│ │
│ 3. Backend receives terminate request │
│ - Calls Meta API: terminate_call(call_id) │
│ - Updates WhatsappCall → "ended" │
│ - Updates message → "completed" + duration │
│ - Broadcasts whatsapp_call.ended via ActionCable │
│ │
│ 4. Recording is LOST (MediaRecorder chunks are in memory) │
│ │
│ ❌ Call cannot survive — WebRTC is peer-to-peer │
└──────────────────────────────────────────────────────────────────────┘
Agent Browser Backend Meta
│ │ │
│ ═══ Active call (audio) ════════════════════════════════════► │
│ │ │
│── beforeunload fires │ │
│ │ │
│── fetch("/terminate", │ │
│ {keepalive: true}) ──────────►│ │
│ │── terminate_call ───────────►│
│── cleanupInboundWebRTC() │ │
│ (mic off, PC closed) │── Update call → ended │
│ │── Update msg → completed │
│ │── ActionCable: ended │
│── Page dies │ │
Why we read auth headers manually:
- The app uses
devise_token_authwith headers (access-token,client,uid,expiry) - These are stored in a
cw_d_session_infocookie as JSON - We parse the cookie directly instead of using axios interceptors (which won't run during unload)
What about the recording?
- The
MediaRecorderchunks live in a JavaScript array (recordedChunks[]) - On page unload, this memory is freed — the recording is lost
- Only recordings from calls that end normally (hang up button) are saved
- A future improvement could periodically upload partial chunks during the call
Summary table
| Call state | Page refresh | Page close |
|---|---|---|
| Ringing (not accepted) | Call survives. Agent can accept from message bubble after reload. | Call stays ringing on Meta's side. Auto-dismissed after 30s widget timeout. |
| In progress (accepted) | Call terminated via beforeunload. Recording lost. |
Call terminated via beforeunload. Recording lost. |
| Ended | No effect. | No effect. |
6. Call Recording & Transcription
Why client-side recording?
Meta does not provide call recordings, transcriptions, or audio stream access through their API. From their FAQ:
"Does Meta offer services such as voice recording, transcript, and voicemail features? No."
However, Meta does provide the raw audio stream via WebRTC. Since the RTCPeerConnection lives in the agent's browser, the browser has direct access to both audio tracks:
- Local track — the agent's microphone (
getUserMedia) - Remote track — the caller's audio (delivered via
pc.ontrack)
This is what makes client-side recording possible without any Meta API support.
How recording works
┌─────────────────────────────────────────────────────────────┐
│ Agent's Browser │
│ │
│ Local mic ──────┐ │
│ (getUserMedia) │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ AudioContext │ │
│ │ │ │
│ │ localSource ─┤ │
│ │ ├──► MediaStreamDestination │
│ │ remoteSource ┤ (mixed audio) │
│ │ │ │ │
│ └───────────────┘ │ │
│ ▼ │
│ Remote audio ──┘ ┌──────────────┐ │
│ (pc.ontrack) │ MediaRecorder │ │
│ │ audio/webm │ │
│ │ opus codec │ │
│ │ │ │
│ │ Collects 1s │ │
│ │ chunks into │ │
│ │ array │ │
│ └──────┬───────┘ │
│ │ │
└───────────────────────────────────────┼─────────────────────┘
│
On call end
│
▼
┌──────────────┐
│ Blob (webm) │
│ │
│ POST /upload │
│ _recording │
│ (multipart) │
└──────┬───────┘
│
▼
┌───────────────┐
│ Backend │
│ │
│ ActiveStorage │
│ .attach() │
│ │
│ Update message │
│ with recording │
│ URL │
│ │
│ Enqueue │
│ transcription │
│ job │
└───────┬───────┘
│
▼
┌───────────────┐
│ Transcription │
│ Job (Sidekiq) │
│ │
│ Download from │
│ ActiveStorage │
│ │ │
│ ▼ │
│ OpenAI Whisper │
│ (whisper-1) │
│ │ │
│ ▼ │
│ Save transcript│
│ to call record │
│ + message │
└───────────────┘
Recording step by step
Here's exactly what happens in code, from call connect to transcript:
Step 1: Recording starts (on pc.ontrack — when remote audio arrives)
File: useWhatsappCallSession.js → startCallRecording(pc, localStream, callId)
a. Create an AudioContext (Web Audio API)
b. Create a MediaStreamDestination (the "mixing board")
c. Connect local mic track → destination
d. Connect remote caller track(s) → destination
(reads from pc.getReceivers() — all audio tracks from the peer connection)
e. Create MediaRecorder on the mixed destination stream
- Format: audio/webm;codecs=opus
- Chunk interval: 1 second
f. recorder.ondataavailable → push each chunk to recordedChunks[]
g. recorder.start(1000) — begin recording
For inbound calls, this happens inside doAcceptCall() → pc.ontrack.
For outbound calls, this happens inside ConversationHeader.vue → pc.ontrack (when the contact picks up).
Step 2: Call is active — recording accumulates in memory
During the call:
- Every 1 second, MediaRecorder fires ondataavailable
- Each chunk (~5-15 KB) is pushed to the recordedChunks[] array
- A 5-minute call ≈ 300 chunks ≈ 1.5-4.5 MB in memory
- The recording is NOT yet on the server — it only exists in browser memory
Step 3: Call ends — recording is uploaded
Triggered by:
- Agent clicks "Hang up" → endActiveCall() → stopAndUploadRecording()
- External end (caller hangs up) → ActionCable whatsapp_call.ended
→ store.handleCallEnded() → cleanupCallback → stopAndUploadRecording()
stopAndUploadRecording(callId):
a. mediaRecorder.stop()
b. In recorder.onstop callback:
- new Blob(recordedChunks, {type: 'audio/webm'}) — merge all chunks
- WhatsappCallsAPI.uploadRecording(callId, blob) — POST multipart
- recordedChunks = [] — free memory
Step 4: Backend processes the upload
POST /api/v1/accounts/:id/whatsapp_calls/:id/upload_recording
a. Validate call is terminal (ended/missed/failed)
b. ActiveStorage.attach(recording file)
c. Update message content_attributes with recording_url
d. Enqueue Whatsapp::CallTranscriptionJob
Step 5: Transcription job runs (async, Sidekiq low queue)
Whatsapp::CallTranscriptionJob:
a. Download recording from ActiveStorage to tmp file
b. Send to OpenAI Whisper API (whisper-1 model)
c. Save transcript to WhatsappCall.transcript column
d. Update message content_attributes with transcript text
e. Clean up tmp file
When recording is saved vs lost
| Scenario | Recording saved? | Why |
|---|---|---|
| Agent clicks "Hang up" | Yes | stopAndUploadRecording() runs before cleanup |
| Caller hangs up (terminate webhook) | Yes | ActionCable whatsapp_call.ended triggers cleanup callback which uploads |
| Agent refreshes during active call | No | beforeunload terminates the call but MediaRecorder chunks in memory are freed before upload completes |
| Agent closes tab during active call | No | Same as refresh — memory freed on page death |
| Browser crashes | No | No cleanup handlers run at all |
| Call < 1 second | No | No chunks collected yet (1s interval) |
Future improvement: Periodically upload partial recording chunks during the call (e.g., every 30 seconds). This would ensure most of the recording survives even if the page dies.
What the agent sees (progressive updates)
┌──────────────────────────────────────────────────────────────────┐
│ │
│ Immediately on call end: │
│ ┌──────────────────────────┐ │
│ │ 📞 Call ended │ │
│ │ Answered by John │ │
│ │ 2m 34s │ │
│ └──────────────────────────┘ │
│ │
│ ~2-5 seconds later (recording upload completes): │
│ ┌──────────────────────────┐ │
│ │ 📞 Call ended │ │
│ │ Answered by John │ │
│ │ 2m 34s │ │
│ │ ▶ ────●──────── 2:34 │ ← native <audio> player │
│ └──────────────────────────┘ │
│ │
│ ~10-30 seconds later (transcription job completes): │
│ ┌──────────────────────────┐ │
│ │ 📞 Call ended │ │
│ │ Answered by John │ │
│ │ 2m 34s │ │
│ │ ▶ ────●──────── 2:34 │ │
│ │ ▸ Transcript │ ← click to expand │
│ │ "Hi, I wanted to ask │ │
│ │ about my order..." │ │
│ └──────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
These updates happen via message.updated ActionCable events — the backend updates content_attributes and the VoiceCall bubble re-renders reactively.
Prerequisites for transcription
| Requirement | How to check |
|---|---|
captain_integration feature flag |
account.feature_enabled?('captain_integration') |
| OpenAI API key configured | InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY') |
| Available usage quota | account.usage_limits[:captain][:responses][:current_available] > 0 |
If any prerequisite is missing, the recording is still saved — only transcription is skipped. No errors are raised; the job returns early with { error: 'Transcription not available' }.
7. Data Model
calls table (unified Call model)
WhatsApp calls use the unified Call model (from PR #14026) with provider: :whatsapp.
| Column | Type | Description |
|---|---|---|
id |
bigint | Primary key |
account_id |
bigint | FK → accounts |
inbox_id |
bigint | FK → inboxes |
conversation_id |
bigint | FK → conversations |
accepted_by_agent_id |
bigint | FK → users (nullable) |
message_id |
bigint | FK → messages (nullable) — the voice_call message |
provider_call_id |
string | Unique provider call ID (Meta call ID for WhatsApp) |
provider |
integer | Enum: twilio (0), whatsapp (1) |
direction |
integer | Enum: incoming (0), outgoing (1) |
status |
string | ringing, in_progress, completed, no_answer, failed |
started_at |
datetime | When the call was answered |
duration_seconds |
integer | Call duration (set on termination) |
end_reason |
string | Reason from Meta (e.g., caller_hangup) |
meta |
jsonb | Stores sdp_offer, sdp_answer, ice_servers |
transcript |
text | Whisper transcription of the call |
created_at |
datetime | |
updated_at |
datetime |
ActiveStorage attachment: has_one_attached :recording (audio/webm)
Indexes: [provider, provider_call_id] (unique), [account_id, conversation_id], message_id
Voice call message structure
{
"content": "WhatsApp Call",
"content_type": "voice_call",
"message_type": 0,
"content_attributes": {
"data": {
"call_sid": "meta_call_id_abc123",
"status": "completed",
"call_direction": "inbound",
"call_source": "whatsapp",
"wa_call_id": 42,
"from_number": "+1234567890",
"to_number": "+0987654321",
"accepted_by": { "id": 7, "name": "Agent Smith" },
"duration_seconds": 135,
"recording_url": "/rails/active_storage/blobs/.../call-42.webm",
"transcript": "Hi, I wanted to ask about my order...",
"meta": { "created_at": 1711180800 }
}
}
}
Status lifecycle
┌──────────┐
│ ringing │
└──┬───┬───┘
│ │
accepted │ │ no answer / timeout / reject
│ │
┌────▼┐ │ ┌──────────┐
│accepted│ ├──►│ rejected │
└────┬───┘ │ └──────────┘
│ │ ┌──────────┐
ended │ ├──►│ missed │
│ │ └──────────┘
┌────▼──┐ │ ┌──────────┐
│ ended │ └──►│ failed │
└───────┘ └──────────┘
8. API Reference
Base: POST /api/v1/accounts/{account_id}/whatsapp_calls
| Endpoint | Method | Purpose | Key params |
|---|---|---|---|
/{id} |
GET | Show call details | — |
/initiate |
POST | Start outbound call | conversation_id, sdp_offer |
/{id}/accept |
POST | Accept ringing call | sdp_answer |
/{id}/reject |
POST | Reject ringing call | — |
/{id}/terminate |
POST | End active call | — |
/{id}/upload_recording |
POST | Upload call recording | recording (multipart file) |
Notable responses
Initiate — permission flow:
200 { status: 'calling', call_id, id }— call initiated200 { status: 'permission_requested' }— contact needs to grant permission200 { status: 'permission_pending' }— already requested recently
Accept — race condition:
422 { error: 'Call already accepted by another agent' }— another agent won422 { error: 'Call is not in ringing state' }— call ended/timed out
9. WebSocket Events
All events are broadcast on the account_{account_id} ActionCable channel.
| Event | When | Key payload |
|---|---|---|
whatsapp_call.incoming |
New inbound call | id, call_id, sdp_offer, ice_servers, caller |
whatsapp_call.accepted |
Agent accepted | call_id, accepted_by_agent_id |
whatsapp_call.outbound_connected |
Contact answered outbound call | call_id, sdp_answer |
whatsapp_call.ended |
Call ended (any reason) | call_id, status, duration_seconds |
whatsapp_call.permission_granted |
Contact granted call permission | contact_name |
Standard message.created and message.updated events also fire when voice_call messages change.
10. Key Files
Backend (Enterprise)
| File | Purpose |
|---|---|
enterprise/app/models/whatsapp_call.rb |
Data model + has_one_attached :recording |
enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb |
REST API |
enterprise/app/services/whatsapp/call_service.rb |
Accept/reject/terminate with Meta |
enterprise/app/services/whatsapp/incoming_call_service.rb |
Webhook → call record + message |
enterprise/app/services/whatsapp/call_message_builder.rb |
Creates/updates voice_call messages |
enterprise/app/services/whatsapp/call_transcription_service.rb |
Whisper transcription |
enterprise/app/jobs/whatsapp/call_transcription_job.rb |
Async transcription job |
enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb |
Meta API calls |
enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb |
Routes call webhooks |
Frontend
| File | Purpose |
|---|---|
dashboard/components-next/message/bubbles/VoiceCall.vue |
Call message bubble (recording + transcript) |
dashboard/stores/whatsappCalls.js |
Pinia store for call state |
dashboard/composables/useWhatsappCallSession.js |
WebRTC, recording, mute, beforeunload |
dashboard/api/whatsappCalls.js |
API client |
dashboard/components/widgets/WhatsappCallWidget.vue |
Floating call widget |
dashboard/components/widgets/conversation/ConversationHeader.vue |
Outbound call initiation |
dashboard/helper/voice.js |
Routes WhatsApp vs Twilio events |
dashboard/helper/actionCable.js |
WebSocket event handlers |
11. Challenges & Design Decisions
1. No trickle ICE
Problem: Most WebRTC implementations use "trickle ICE" — sending ICE candidates one by one as they're discovered. Meta's API doesn't support this. It requires a complete SDP with all candidates baked in.
Solution: We wait up to 10 seconds for ICE gathering to complete before sending the SDP. If it times out, we send a partial SDP (which may cause connection issues on restrictive networks).
// useWhatsappCallSession.js
const timeout = setTimeout(() => resolve(), 10000);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(timeout);
resolve();
}
};
2. SDP setup line mismatch
Problem: Browsers generate a=setup:actpass in SDP answers, but Meta requires a=setup:active.
Solution: Backend rewrites the SDP before sending to Meta:
# call_service.rb
def fix_sdp_setup(sdp)
sdp.gsub('a=setup:actpass', 'a=setup:active')
end
3. Two-phase accept (pre_accept + accept)
Problem: Meta requires two separate API calls to accept a call — pre_accept_call first, then accept_call. Both need the same SDP answer.
Solution: CallService#pre_accept_and_accept runs both inside a database row lock to prevent race conditions when multiple agents try to accept simultaneously.
4. Race condition — multiple agents accepting
Problem: When a call comes in, all online agents see it. Two agents might click "Accept" at the same millisecond.
Solution: The accept logic runs inside wa_call.with_lock do ... end (Postgres row-level lock). The first agent succeeds; the second gets a 422 AlreadyAccepted error. The ActionCable whatsapp_call.accepted event removes the call from other agents' widgets.
5. Page close kills the call
Problem: If an agent closes the tab during a call, the WebRTC connection drops but the backend doesn't know — the call lingers as "in progress" on Meta's side.
Solution: A beforeunload handler sends a fire-and-forget terminate request using fetch with keepalive: true, which completes even after the page unloads. Auth headers are read from the session cookie.
6. Accept after page refresh
Problem: If an agent refreshes while a call is ringing, the in-memory call state (Pinia store + SDP offer) is lost.
Solution: The "Accept" button on the VoiceCall.vue message bubble calls acceptWhatsappCallById(), which fetches the call's SDP offer from the backend API (GET /whatsapp_calls/:id) if it's not in the store. This only works while the call is still ringing.
7. Client-side recording
Problem: Meta doesn't provide call recordings or audio stream access. The audio only exists in the browser's RTCPeerConnection.
Solution: We use the Web Audio API to mix both tracks and MediaRecorder to capture the audio client-side. The recording is uploaded after the call ends. This means if the browser crashes, the recording is lost.
8. WhatsApp vs Twilio in the same UI
Problem: The VoiceCall.vue bubble and voice infrastructure were built for Twilio. WhatsApp calls have different semantics (peer-to-peer vs conference, different status names).
Solution:
call_source: 'whatsapp'in message content_attributes distinguishes the twoCallMessageBuildermaps WhatsApp statuses to Voice statusesvoice.jshelper routes events to the correct storeshowJoinButtonin VoiceCall.vue has different logic per call source (WhatsApp only shows during ringing; Twilio shows during in-progress too because it's conference-based)
12. Extending the Feature
Adding video calls
Meta's calling API currently supports audio only, but they've announced video is coming. When it arrives:
- Add
{ audio: true, video: true }togetUserMedia - Add video track to
RTCPeerConnection - Add a
<video>element for remote stream (instead of<audio>) - Update
MediaRecorderto record video (change MIME type) - Update the call widget to show video preview
Adding real-time transcription
Currently transcription is post-call. For real-time:
- Create an
AudioWorkletor useScriptProcessorNodeto extract audio chunks from the remote track - Stream chunks to a real-time STT service (e.g., Deepgram, Google Cloud Speech streaming)
- Display live captions in the call widget
- Store final transcript on call end
Adding call transfer
Not currently supported by Meta's API, but when available:
- Add
transfer_call(call_id, target_agent_id)to the provider service - Create a transfer button in the call widget
- Handle the new agent receiving the transferred SDP
- Update the
WhatsappCallrecord with the new agent
Adding call analytics / dashboards
The WhatsappCall model already tracks duration, direction, status, and agent. Possible additions:
- Add
wait_time_seconds(time from ringing to accept) - Add
rating(post-call CSAT) - Build aggregate queries for call volume, avg duration, missed call rate
- Create a dedicated calls dashboard view
Adding SIP trunk support
Meta supports SIP as an alternative to WebRTC:
- Configure SIP endpoint in the provider config
- Route calls through Asterisk/FreeSWITCH instead of browser WebRTC
- This enables server-side recording, IVR, and conference bridges
- See Meta docs:
Default: SIP with WebRTCvsSIP (Explicit): SIP with SDES media