<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Plugiverse</title>
    <description>The latest articles on DEV Community by Plugiverse (@plugiverse).</description>
    <link>https://dev.to/plugiverse</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4226069%2F1439b3aa-0a1a-4666-b18f-cf41341b3c78.jpg</url>
      <title>DEV Community: Plugiverse</title>
      <link>https://dev.to/plugiverse</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/plugiverse"/>
    <language>en</language>
    <item>
      <title>WhatsApp Business Calling API: fixing silent calls on cloud hosts (TURN relay + non-trickle ICE)</title>
      <dc:creator>Plugiverse</dc:creator>
      <pubDate>Wed, 04 Nov 2026 11:39:51 +0000</pubDate>
      <link>https://dev.to/plugiverse/whatsapp-business-calling-api-fixing-silent-calls-on-cloud-hosts-turn-relay-non-trickle-ice-2g1h</link>
      <guid>https://dev.to/plugiverse/whatsapp-business-calling-api-fixing-silent-calls-on-cloud-hosts-turn-relay-non-trickle-ice-2g1h</guid>
      <description>&lt;p&gt;You've wired up the WhatsApp Business Calling API. The &lt;code&gt;connect&lt;/code&gt; webhook arrives, you send back an SDP answer, &lt;code&gt;pre_accept&lt;/code&gt; and &lt;code&gt;accept&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This post walks through the two causes behind most of those silent calls, and how to fix them in Node.js:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ICE candidates missing from your SDP&lt;/strong&gt;, because Meta's calling signalling is non-trickle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your cloud host doesn't accept inbound UDP&lt;/strong&gt;, so you need a TURN relay and &lt;code&gt;iceTransportPolicy: 'relay'&lt;/code&gt; on server-side peers.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick background: how a user-initiated call is answered
&lt;/h2&gt;

&lt;p&gt;When a user taps the call icon in your WhatsApp chat, Meta sends you a &lt;code&gt;connect&lt;/code&gt; webhook containing an &lt;strong&gt;SDP offer&lt;/strong&gt; in &lt;code&gt;session.sdp&lt;/code&gt;. You answer with a WebRTC peer (a browser, or a server library such as &lt;code&gt;@roamhq/wrtc&lt;/code&gt;, &lt;code&gt;werift&lt;/code&gt;, Pion or aiortc) and send your &lt;strong&gt;SDP answer&lt;/strong&gt; back through the Graph API:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive &lt;code&gt;connect&lt;/code&gt; with &lt;code&gt;session.sdp&lt;/code&gt; (the offer).&lt;/li&gt;
&lt;li&gt;Create an &lt;code&gt;RTCPeerConnection&lt;/code&gt; with your ICE servers (STUN &lt;strong&gt;and&lt;/strong&gt; TURN).&lt;/li&gt;
&lt;li&gt;Add a local audio track.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;setRemoteDescription(offer)&lt;/code&gt;, &lt;code&gt;createAnswer()&lt;/code&gt;, &lt;code&gt;setLocalDescription(answer)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wait for ICE gathering to complete.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Normalise the fingerprint line (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /&amp;lt;PHONE_NUMBER_ID&amp;gt;/calls&lt;/code&gt; with &lt;code&gt;action: "pre_accept"&lt;/code&gt; and your SDP answer.&lt;/li&gt;
&lt;li&gt;When your media path is ready, &lt;code&gt;POST&lt;/code&gt; again with &lt;code&gt;action: "accept"&lt;/code&gt; and the same SDP.&lt;/li&gt;
&lt;li&gt;Start sending audio.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2 and 5 are where silent calls come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: you sent the SDP before ICE gathering finished
&lt;/h2&gt;

&lt;p&gt;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. &lt;strong&gt;WhatsApp calling doesn't work that way.&lt;/strong&gt; The signalling is non-trickle: there is no channel for late candidates, so every candidate must already be inside the SDP you send with &lt;code&gt;pre_accept&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you call &lt;code&gt;pre_accept&lt;/code&gt; right after &lt;code&gt;setLocalDescription()&lt;/code&gt;, the SDP often contains few or no usable candidates. The API accepts it, the call "connects", and no media ever flows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; call connects, silence both ways.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; wait for &lt;code&gt;iceGatheringState === 'complete'&lt;/code&gt; before you read &lt;code&gt;pc.localDescription.sdp&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitIceComplete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iceGatheringState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iceGatheringState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;icegatheringstatechange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// send what you have rather than hang&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read &lt;code&gt;pc.localDescription.sdp&lt;/code&gt; &lt;strong&gt;after&lt;/strong&gt; the wait, not the answer object &lt;code&gt;createAnswer()&lt;/code&gt; returned. The local description is the one that gets updated with gathered candidates.&lt;/li&gt;
&lt;li&gt;The timeout is a safety net. If gathering stalls, you still send what you have rather than leaving the caller ringing forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cause 2: your host doesn't accept inbound UDP
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; put a &lt;strong&gt;TURN&lt;/strong&gt; relay in your ICE servers, and on server peers set &lt;code&gt;iceTransportPolicy: 'relay'&lt;/code&gt; so the connection only uses relayed candidates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stun:stun.example.com:3478&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turn:turn.example.com:3478&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;turnUsername&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// short-lived, from your TURN provider&lt;/span&gt;
      &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;turnCredential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;iceTransportPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// server peers: only use relayed candidates&lt;/span&gt;
  &lt;span class="na"&gt;bundlePolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-bundle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The hostnames above are placeholders. Use the URLs and credentials your TURN provider gives you.)&lt;/p&gt;

&lt;p&gt;Why &lt;code&gt;relay&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;A few notes on credentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mint &lt;strong&gt;short-lived&lt;/strong&gt; TURN credentials from your TURN provider and cache them until shortly before they expire.&lt;/li&gt;
&lt;li&gt;Never ship long-lived TURN secrets to browsers.&lt;/li&gt;
&lt;li&gt;Browsers behind corporate NAT can need TURN too, not just servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;Here's the answer flow with both fixes in place, plus one more gotcha that bites at the same step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GRAPH_VERSION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the Graph API version you target&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WA_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;messaging_product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;whatsapp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;call_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pre_accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sdp_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://graph.facebook.com/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;GV&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;phoneId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/calls`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({}));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Meta rejects a lowercase hash name on the fingerprint line with HTTP 400.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fixFingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/a=fingerprint:sha-256/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a=fingerprint:SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;phoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// STUN + TURN&lt;/span&gt;
    &lt;span class="na"&gt;iceTransportPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// server peer on a cloud host&lt;/span&gt;
    &lt;span class="na"&gt;bundlePolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max-bundle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRemoteDescription&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createAnswer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitIceComplete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                         &lt;span class="c1"&gt;// non-trickle&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fixFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localDescription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pre_accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fingerprint line: libwebrtc writes &lt;code&gt;a=fingerprint:sha-256&lt;/code&gt;, and Meta expects &lt;code&gt;SHA-256&lt;/code&gt;. If &lt;code&gt;pre_accept&lt;/code&gt; or &lt;code&gt;accept&lt;/code&gt; returns a 400 even though your SDP looks fine, that's the first thing to check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;pre_accept&lt;/code&gt; before &lt;code&gt;accept&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pre_accept&lt;/code&gt; 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 &lt;code&gt;accept&lt;/code&gt; when your agent or bot is genuinely ready. That's another reason to get gathering right: a &lt;code&gt;pre_accept&lt;/code&gt; with no usable candidates wastes the head start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging checklist
&lt;/h2&gt;

&lt;p&gt;When a call connects with no audio, go through these in order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Call connects, silence both ways&lt;/td&gt;
&lt;td&gt;SDP sent before ICE gathering completed&lt;/td&gt;
&lt;td&gt;Wait for &lt;code&gt;iceGatheringState === 'complete'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works locally, silent in the cloud&lt;/td&gt;
&lt;td&gt;Host doesn't accept inbound UDP&lt;/td&gt;
&lt;td&gt;TURN relay + &lt;code&gt;iceTransportPolicy: 'relay'&lt;/code&gt; on server peers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pre_accept&lt;/code&gt; / &lt;code&gt;accept&lt;/code&gt; returns 400&lt;/td&gt;
&lt;td&gt;Lowercase &lt;code&gt;sha-256&lt;/code&gt; in the fingerprint line&lt;/td&gt;
&lt;td&gt;Upper-case it to &lt;code&gt;SHA-256&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser never asks for the microphone&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Permissions-Policy&lt;/code&gt; header with &lt;code&gt;microphone=()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Serve &lt;code&gt;Permissions-Policy: microphone=(self)&lt;/code&gt; over HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server audio track errors on every frame&lt;/td&gt;
&lt;td&gt;Passing a typed-array view (&lt;code&gt;subarray&lt;/code&gt;) to a native binding&lt;/td&gt;
&lt;td&gt;Pass a copied slice of exactly one 10 ms frame (480 samples at 48 kHz mono)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And a pre-flight list for server-side answering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] TURN in your ICE servers, relay policy on server peers&lt;/li&gt;
&lt;li&gt;[ ] Non-trickle ICE: wait for gathering before &lt;code&gt;pre_accept&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Fingerprint normalisation&lt;/li&gt;
&lt;li&gt;[ ] Short-lived TURN credentials, never long-lived secrets in the browser&lt;/li&gt;
&lt;li&gt;[ ] Return &lt;code&gt;200&lt;/code&gt; on the webhook immediately and process asynchronously, so slow acknowledgements don't trigger retries and duplicate events&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you've hit a different cause of silent calls, I'd like to hear about it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;We cover the full call lifecycle (outbound calls, webhooks, SIP and production checks) in a paid field guide: &lt;a href="https://www.plugiverse.biz/products/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch_nov26" rel="noopener noreferrer"&gt;https://www.plugiverse.biz/products/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch_nov26&lt;/a&gt;. Not affiliated with Meta or n8n.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>webrtc</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
