DEV Community

Cover image for Answering WhatsApp Calls in the Browser: The Calling API, WebRTC and the Webhooks in Between
WAPGROWAI for WAPGROWAI

Posted on

Answering WhatsApp Calls in the Browser: The Calling API, WebRTC and the Webhooks in Between

The WhatsApp Business Calling API is WebRTC with webhooks for signalling. A customer's call reaches your server as a connect webhook carrying an SDP offer. The agent's browser turns that offer into an SDP answer with RTCPeerConnection. Your server sends the answer to Meta with pre_accept and then accept, and audio flows between the browser and WhatsApp. This post walks through that flow, then the things that broke when we built it into a team inbox: the two meanings of connect, ICE gathering, TURN, ringing the right agent inside a 60-second window, and logging each call exactly once.

The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.

Before the first call

Meta's calling overview lists the prerequisites:

  • The number is on the Cloud API, "not the WhatsApp Business app". Numbers onboarded with coexistence can't use it: Meta's coexistence guide lists voice and video calls as "Not supported" on the Cloud API side.
  • Your app is subscribed to the calls webhook field, as well as messages.
  • The number has "a daily messaging limit of at least 2,000 unique recipients". Test numbers are exempt.
  • Calling is switched on for the number. It's off by default.

Switching it on is a settings call. The value is a string status, not a boolean; when we first sent { "enabled": true }, Graph rejected the key.

curl -X POST "https://graph.facebook.com/<VERSION>/<PHONE_NUMBER_ID>/settings" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "calling": { "status": "ENABLED" } }'
Enter fullscreen mode Exit fullscreen mode

The same calling object also takes call hours, a holiday schedule, call icon visibility and a callback-permission prompt, all on Meta's call settings page.

An inbound call, step by step

Sequence of an inbound WhatsApp call: connect webhook with SDP offer, browser builds an SDP answer, server sends pre_accept then accept, audio flows, terminate webhook
An inbound call: webhooks carry the signaling, WebRTC carries the audio.

  1. The customer taps call. Meta sends a calls webhook with event: "connect", direction: "USER_INITIATED" and session: { sdp_type: "offer", sdp }.
  2. Your server rings an agent and hands the offer to their browser, over a socket in our case.
  3. The agent clicks Accept. The browser captures the microphone, applies the offer, creates an answer and gathers ICE candidates.
  4. Your server sends pre_accept with that answer to POST /<PHONE_NUMBER_ID>/calls. Meta marks this step "recommended" in its user-initiated calls guide, because it lets the media connection come up before the call is accepted and "avoids audio clipping issues".
  5. Your server sends accept with the same answer. Meta says to wait for the 200 OK before sending media.
  6. Someone hangs up. Your side sends terminate, or Meta sends a terminate webhook with status and duration.

The clock matters. Meta's guide says you have "about 30 to 60 seconds after the Call Connect Webhook is sent to accept the phone call". After that, the customer sees "Not Answered" and you get a terminate webhook.

The request bodies are small. One thing to notice: every action carries one complete SDP, and nothing else.

// Bodies for POST /<PHONE_NUMBER_ID>/calls. One complete SDP per action.
type Session = { sdp_type: "offer" | "answer"; sdp: string };

export const preAcceptBody = (callId: string, answerSdp: string) => ({
  messaging_product: "whatsapp",
  call_id: callId,
  action: "pre_accept",
  session: { sdp_type: "answer", sdp: answerSdp } satisfies Session,
});

// Must carry the same SDP as pre_accept, or Meta rejects it.
export const acceptBody = (callId: string, answerSdp: string) => ({
  ...preAcceptBody(callId, answerSdp),
  action: "accept",
});

export const endBody = (callId: string, action: "reject" | "terminate") => ({
  messaging_product: "whatsapp",
  call_id: callId,
  action,
});

export const answerCall = async (
  post: (body: object) => Promise<{ ok: boolean }>,
  callId: string,
  answerSdp: string,
) => {
  const pre = await post(preAcceptBody(callId, answerSdp));
  if (!pre.ok) return "pre_accept failed";
  const accepted = await post(acceptBody(callId, answerSdp));
  // Only start sending audio once accept has returned 200 OK.
  return accepted.ok ? "accepted" : "accept failed";
};
Enter fullscreen mode Exit fullscreen mode

Building the answer in the browser

This is standard WebRTC. The offer comes from the webhook, and the answer goes back through your server:

import { waitForIceGathering } from "./ice.ts";

// Runs in the agent's browser when they click Accept.
export const buildAnswer = async (
  offerSdp: string,
  iceServers: RTCIceServer[],
  onRemoteAudio: (stream: MediaStream) => void,
) => {
  const mic = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
  const pc = new RTCPeerConnection({ iceServers });
  for (const track of mic.getTracks()) pc.addTrack(track, mic);

  pc.addEventListener("track", (event) => {
    // A new MediaStream each time, so a UI holding the old one re-binds.
    onRemoteAudio(new MediaStream([event.track]));
  });

  await pc.setRemoteDescription({ type: "offer", sdp: offerSdp });
  await pc.setLocalDescription(await pc.createAnswer());
  await waitForIceGathering(pc);

  // localDescription now includes the gathered candidates.
  return { pc, answerSdp: pc.localDescription!.sdp };
};
Enter fullscreen mode Exit fullscreen mode

Two details in there cost us time.

Gather ICE before you send the SDP. In most WebRTC apps you trickle candidates to the other side as they appear. Meta's docs describe no way to do that here: each /calls action takes one SDP, and there's no candidate-update action. The SDP returned by createAnswer() has no candidates yet, so wait for gathering to finish and send pc.localDescription.sdp instead. Put a timeout on the wait, because gathering can hang on a network with an unreachable STUN or TURN server, and you only have a minute:

// Resolve once ICE gathering is complete, or after a timeout with what we have.
type GatheringPeer = Pick<RTCPeerConnection, "iceGatheringState" | "addEventListener" | "removeEventListener">;

export const waitForIceGathering = (pc: GatheringPeer, timeoutMs = 4000): Promise<"complete" | "timeout"> =>
  new Promise((resolve) => {
    if (pc.iceGatheringState === "complete") return resolve("complete");
    const onChange = () => {
      if (pc.iceGatheringState !== "complete") return;
      clearTimeout(timer);
      pc.removeEventListener("icegatheringstatechange", onChange);
      resolve("complete");
    };
    const timer = setTimeout(() => {
      pc.removeEventListener("icegatheringstatechange", onChange);
      resolve("timeout");
    }, timeoutMs);
    pc.addEventListener("icegatheringstatechange", onChange);
  });
Enter fullscreen mode Exit fullscreen mode

One-way audio is often a UI bug, not a network bug. We had calls where the customer could hear the agent, but the agent heard nothing. The remote track had arrived, but we had added it to a MediaStream the React component already held. addTrack() on an existing stream doesn't fire an event and doesn't change the object reference, so the <audio> element never re-bound. Handing the UI a new MediaStream on every track event fixed it.

STUN works on your desk, TURN works in the office

Public STUN is enough for a demo on home Wi-Fi. Agents on corporate networks, VPNs or mobile hotspots often sit behind NATs and firewalls that STUN can't get through, and the symptom is a call that "connects" with no audio either way. Run a TURN server for production (coturn is the usual choice), give the browser short-lived credentials, and treat "no TURN configured" as a deployment error, not a nice-to-have.

connect means two different things

When you call a customer, the flow mirrors the inbound one. You POST /calls with action: "connect", the customer's number and your SDP offer, and Meta returns a call ID. Meta then sends a connect webhook carrying its SDP answer, before the customer has picked up.

So the same connect event either means "a customer is calling" (an offer) or "media for your outbound call is ready" (an answer). Look the call ID up before you do anything else:

// Turn a `calls` webhook entry into one thing your app should do.
export type CallEvent = {
  id: string;
  event: string;
  direction?: "USER_INITIATED" | "BUSINESS_INITIATED";
  from?: string;
  session?: { sdp_type: "offer" | "answer"; sdp: string };
  status?: string;
  duration?: number;
};

export type CallAction =
  | { kind: "ring"; callId: string; from: string; offerSdp: string }
  | { kind: "apply-answer"; callId: string; answerSdp: string }
  | { kind: "ended"; callId: string; outcome: "completed" | "failed"; durationSeconds: number }
  | { kind: "ignore"; reason: string };

export const routeCallEvent = (call: CallEvent, knownCallIds: Set<string>): CallAction => {
  if (call.event === "connect") {
    // A call we placed: Meta's answer to our offer, before the customer picks up.
    if (knownCallIds.has(call.id)) {
      if (call.session?.sdp_type !== "answer") return { kind: "ignore", reason: "outbound connect without answer" };
      return { kind: "apply-answer", callId: call.id, answerSdp: call.session.sdp };
    }
    // A new call from a customer carries their offer.
    if (call.direction === "USER_INITIATED" && call.session?.sdp_type === "offer" && call.from) {
      return { kind: "ring", callId: call.id, from: call.from, offerSdp: call.session.sdp };
    }
    return { kind: "ignore", reason: "connect for an unknown call" };
  }
  if (call.event === "terminate") {
    return {
      kind: "ended",
      callId: call.id,
      outcome: call.status === "FAILED" ? "failed" : "completed",
      durationSeconds: call.duration ?? 0,
    };
  }
  return { kind: "ignore", reason: `unhandled event ${call.event}` };
};
Enter fullscreen mode Exit fullscreen mode

The last ignore branch for connect matters more than it looks. The webhook can arrive before your own code has saved the call ID that /calls returned. Without the USER_INITIATED and offer checks, that race logs your outbound call as a brand-new incoming one.

Two more rules for outbound calls:

  • Pickup is a status, not connect. The customer actually answering arrives as a statuses entry with type: "call" and status: "ACCEPTED". Start the call timer there; starting it on connect counts the ringing.
  • You need permission first. Without it, /calls fails with error 138006, per Meta's business-initiated calls guide. You ask with a call_permission_request, sent as an interactive message inside the 24-hour window or as a template outside it. Meta allows one request per 24 hours and two per 7 days to each person, and connected calls are capped at 100 per 24 hours. The call permissions guide has the details.

We learned the window rule the hard way. Sent outside the window, the interactive request was accepted with a message ID and then failed later, so the inbox showed "permission request sent" for a request the customer never saw. Check the window before you send.

Billing differs by direction, too: "All user-initiated calls are free", while business-initiated calls are charged in six-second pulses at the rate for the called number's country, according to Meta's calling pricing.

Ring one person, fast

The webhook reaches one server, but an agent has to answer in under a minute. What worked for us:

  • Route to one agent. Ring the agent the chat is assigned to. If nobody owns the chat, ring a chosen fallback person. Ring the whole team only when neither is reachable. Ringing everyone by default makes several people grab the same call.
  • Tell everyone else to stop. When one agent answers, broadcast who answered, so every other client that was ringing drops the call instead of treating it as its own.
  • Push, not just sockets. An agent with the app in a background tab or closed on their phone has no live socket. Send a push notification for every incoming call.
  • Store the offer. A push-notification tap opens the app after the socket event has gone, so save the offer SDP with the call and serve it to whichever client answers. Clear it when the call ends; it's large and describes the caller's network.

Log each call exactly once

When an agent hangs up, you send terminate to Meta, and Meta echoes a terminate webhook back. Both paths want to mark the call ended and write a "call ended, 2:14" entry into the chat. Run both through one conditional update and let only the winner write the log:

// The agent's hang-up and Meta's terminate webhook race; only one may log the call.
export type CallRow = { id: string; status: "ringing" | "answered" | "ended" | "missed"; answeredAt?: number };

export interface CallStore {
  // Atomic: sets status only if the row isn't already terminal; returns rows changed.
  setTerminalIfOpen(id: string, status: "ended" | "missed"): Promise<number>;
}

// SQL version: UPDATE calls SET status = ? WHERE id = ? AND status NOT IN ('ended', 'missed')
export const finishCall = async (
  store: CallStore,
  call: CallRow,
  writeCallLog: (id: string, status: string) => Promise<void>,
) => {
  const status = call.answeredAt ? "ended" : "missed";
  const won = (await store.setTerminalIfOpen(call.id, status)) === 1;
  if (won) await writeCallLog(call.id, status);
  return won;
};
Enter fullscreen mode Exit fullscreen mode

The test runs both paths concurrently and checks for exactly one log entry.

Checklist

  • Enable calling with "status": "ENABLED", and subscribe to the calls field.
  • Check the number isn't a coexistence number and has a messaging limit of at least 2,000.
  • Answer with pre_accept, then accept with the same SDP, and send media only after the 200 OK.
  • Wait for ICE gathering, with a timeout, before sending any SDP.
  • Run TURN in production.
  • Give the audio element a new MediaStream when a remote track arrives.
  • Look up the call ID before treating connect as a new inbound call.
  • Start outbound timers on the ACCEPTED status, not on connect.
  • Send permission requests only inside the window, or use a template.
  • Ring one agent, broadcast who answered, and push to devices.
  • Log each call through a single conditional update.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: handling webhooks in Node.js and testing webhooks outside production.

Top comments (0)