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
callswebhook field, as well asmessages. - 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" } }'
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

An inbound call: webhooks carry the signaling, WebRTC carries the audio.
-
The customer taps call. Meta sends a
callswebhook withevent: "connect",direction: "USER_INITIATED"andsession: { sdp_type: "offer", sdp }. - Your server rings an agent and hands the offer to their browser, over a socket in our case.
- The agent clicks Accept. The browser captures the microphone, applies the offer, creates an answer and gathers ICE candidates.
-
Your server sends
pre_acceptwith that answer toPOST /<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". -
Your server sends
acceptwith the same answer. Meta says to wait for the 200 OK before sending media. -
Someone hangs up. Your side sends
terminate, or Meta sends aterminatewebhook withstatusandduration.
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";
};
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 };
};
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);
});
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}` };
};
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 astatusesentry withtype: "call"andstatus: "ACCEPTED". Start the call timer there; starting it onconnectcounts the ringing. -
You need permission first. Without it,
/callsfails with error 138006, per Meta's business-initiated calls guide. You ask with acall_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;
};
The test runs both paths concurrently and checks for exactly one log entry.
Checklist
- Enable calling with
"status": "ENABLED", and subscribe to thecallsfield. - Check the number isn't a coexistence number and has a messaging limit of at least 2,000.
- Answer with
pre_accept, thenacceptwith 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
MediaStreamwhen a remote track arrives. - Look up the call ID before treating
connectas a new inbound call. - Start outbound timers on the
ACCEPTEDstatus, not onconnect. - 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)