You've wired up the WhatsApp Business Calling API. The connect webhook arrives, you send back an SDP answer, pre_accept and accept both return 200, and the call shows as connected. Then: silence. Sometimes both ways. Sometimes it works fine on your laptop and goes quiet the moment you deploy.
This post walks through the two causes behind most of those silent calls, and how to fix them in Node.js:
- ICE candidates missing from your SDP, because Meta's calling signalling is non-trickle.
- Your cloud host doesn't accept inbound UDP, so you need a TURN relay and
iceTransportPolicy: 'relay'on server-side peers.
Quick background: how a user-initiated call is answered
When a user taps the call icon in your WhatsApp chat, Meta sends you a connect webhook containing an SDP offer in session.sdp. You answer with a WebRTC peer (a browser, or a server library such as @roamhq/wrtc, werift, Pion or aiortc) and send your SDP answer back through the Graph API:
- Receive
connectwithsession.sdp(the offer). - Create an
RTCPeerConnectionwith your ICE servers (STUN and TURN). - Add a local audio track.
-
setRemoteDescription(offer),createAnswer(),setLocalDescription(answer). - Wait for ICE gathering to complete.
- Normalise the fingerprint line (more on that below).
-
POST /<PHONE_NUMBER_ID>/callswithaction: "pre_accept"and your SDP answer. - When your media path is ready,
POSTagain withaction: "accept"and the same SDP. - Start sending audio.
Steps 2 and 5 are where silent calls come from.
Cause 1: you sent the SDP before ICE gathering finished
In a typical WebRTC app, candidates trickle: you send the SDP straight away and stream ICE candidates to the other side as they're discovered. WhatsApp calling doesn't work that way. The signalling is non-trickle: there is no channel for late candidates, so every candidate must already be inside the SDP you send with pre_accept.
If you call pre_accept right after setLocalDescription(), the SDP often contains few or no usable candidates. The API accepts it, the call "connects", and no media ever flows.
Symptom: call connects, silence both ways.
Fix: wait for iceGatheringState === 'complete' before you read pc.localDescription.sdp.
function waitIceComplete(pc, timeoutMs = 5000) { if (pc.iceGatheringState === 'complete') return Promise.resolve(); return new Promise((resolve) => { const check = () => pc.iceGatheringState === 'complete' && resolve(); pc.addEventListener('icegatheringstatechange', check); setTimeout(resolve, timeoutMs); // send what you have rather than hang }); } Two details matter here:
- Read
pc.localDescription.sdpafter the wait, not the answer objectcreateAnswer()returned. The local description is the one that gets updated with gathered candidates. - The timeout is a safety net. If gathering stalls, you still send what you have rather than leaving the caller ringing forever.
Cause 2: your host doesn't accept inbound UDP
The second pattern is "works on my laptop, silent in the cloud". On your machine the peer can receive UDP directly. Many PaaS containers don't accept inbound UDP at all, so the host candidates your server advertises are unreachable and media never arrives.
Fix: put a TURN relay in your ICE servers, and on server peers set iceTransportPolicy: 'relay' so the connection only uses relayed candidates.
const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.example.com:3478' }, { urls: 'turn:turn.example.com:3478', username: turnUsername, // short-lived, from your TURN provider credential: turnCredential, }, ], iceTransportPolicy: 'relay', // server peers: only use relayed candidates bundlePolicy: 'max-bundle', }); (The hostnames above are placeholders. Use the URLs and credentials your TURN provider gives you.)
Why relay rather than letting ICE pick? On a host that drops inbound UDP, the direct candidates look valid in the SDP but go nowhere. Forcing relay means the only candidates you advertise are ones that can actually carry media.
A few notes on credentials:
- Mint short-lived TURN credentials from your TURN provider and cache them until shortly before they expire.
- Never ship long-lived TURN secrets to browsers.
- Browsers behind corporate NAT can need TURN too, not just servers.
Putting it together
Here's the answer flow with both fixes in place, plus one more gotcha that bites at the same step:
const GV = process.env.GRAPH_VERSION; // the Graph API version you target const TOKEN = process.env.WA_TOKEN; async function callAction(phoneId, callId, action, sdp) { const body = { messaging_product: 'whatsapp', call_id: callId, action }; if (sdp && (action === 'pre_accept' || action === 'accept')) { body.session = { sdp_type: 'answer', sdp }; } const res = await fetch(`https://graph.facebook.com/${GV}/${phoneId}/calls`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(`${action} failed ${res.status}: ${JSON.stringify(json)}`); return json; } // Meta rejects a lowercase hash name on the fingerprint line with HTTP 400. const fixFingerprint = (sdp) => sdp.replace(/a=fingerprint:sha-256/gi, 'a=fingerprint:SHA-256'); export async function answer({ phoneId, call, iceServers, track }) { const pc = new RTCPeerConnection({ iceServers, // STUN + TURN iceTransportPolicy: 'relay', // server peer on a cloud host bundlePolicy: 'max-bundle', }); pc.addTrack(track); await pc.setRemoteDescription({ type: 'offer', sdp: call.session.sdp }); await pc.setLocalDescription(await pc.createAnswer()); await waitIceComplete(pc); // non-trickle const sdp = fixFingerprint(pc.localDescription.sdp); await callAction(phoneId, call.id, 'pre_accept', sdp); await callAction(phoneId, call.id, 'accept', sdp); return pc; } That fingerprint line: libwebrtc writes a=fingerprint:sha-256, and Meta expects SHA-256. If pre_accept or accept returns a 400 even though your SDP looks fine, that's the first thing to check.
Why pre_accept before accept?
pre_accept lets Meta start establishing media before you commit. It shortens setup and stops the first syllables of the conversation being clipped. Send it as soon as you have a complete answer SDP, then send accept when your agent or bot is genuinely ready. That's another reason to get gathering right: a pre_accept with no usable candidates wastes the head start.
Debugging checklist
When a call connects with no audio, go through these in order:
| Symptom | Likely cause | Fix |
|---|---|---|
| Call connects, silence both ways | SDP sent before ICE gathering completed | Wait for iceGatheringState === 'complete' |
| Works locally, silent in the cloud | Host doesn't accept inbound UDP | TURN relay + iceTransportPolicy: 'relay' on server peers |
pre_accept / accept returns 400 | Lowercase sha-256 in the fingerprint line | Upper-case it to SHA-256 |
| Browser never asks for the microphone | Permissions-Policy header with microphone=() | Serve Permissions-Policy: microphone=(self) over HTTPS |
| Server audio track errors on every frame | Passing a typed-array view (subarray) to a native binding | Pass a copied slice of exactly one 10 ms frame (480 samples at 48 kHz mono) |
And a pre-flight list for server-side answering:
- [ ] TURN in your ICE servers, relay policy on server peers
- [ ] Non-trickle ICE: wait for gathering before
pre_accept - [ ] Fingerprint normalisation
- [ ] Short-lived TURN credentials, never long-lived secrets in the browser
- [ ] Return
200on the webhook immediately and process asynchronously, so slow acknowledgements don't trigger retries and duplicate events
Wrapping up
Silent WhatsApp calls are rarely a mystery once you know the signalling is non-trickle and that many cloud hosts won't take inbound UDP. Wait for ICE gathering, relay through TURN on server peers, fix the fingerprint case, and most "connected but silent" calls start carrying audio.
If you've hit a different cause of silent calls, I'd like to hear about it in the comments.
This post was written with AI assistance and reviewed and edited by me. The code and fixes come from our own notes on the WhatsApp Business Calling API.
We cover the full call lifecycle (outbound calls, webhooks, SIP and production checks) in a paid field guide: https://www.plugiverse.biz/products/?utm_source=devto&utm_medium=social&utm_campaign=launch_nov26. Not affiliated with Meta or n8n.
