Answering calls yourself
Your own server can be the thing that answers a WhatsApp call. The audio then flows directly between your server and Meta’s media servers — nothing of ours is in the media path, at any point.
Read this before you start
Section titled “Read this before you start”Answering a call is not like replying to a message. Your server has to be a real WebRTC endpoint: it must produce an SDP answer, then carry Opus audio over DTLS-SRTP with Meta for the length of the call. A plain HTTP handler cannot do that.
In practice you need one of:
| Stack | Language |
|---|---|
aiortc |
Python — the lowest-friction option |
werift |
Node — WebRTC in TypeScript |
| LiveKit, mediasoup, Janus | dedicated media servers |
| Asterisk, FreeSWITCH, Jambonz | if you are bridging to SIP or a call centre |
If you only need to know that calls happened, use GET /calls from
Reading and ignore the rest of this page.
How a call reaches you
Section titled “How a call reaches you”Set your number’s inbound reply mode to Forward and point it at a webhook endpoint. A ringing call is then delivered to you with the SDP offer already on it:
{ "event": "call.ringing", "channel": "WHATSAPP", "call": { "id": "0192f3…", "wacid": "wacid.HBgM…", "direction": "INBOUND", "status": "RINGING", "from": "919876543210", "fromName": "Asha Nair", "to": "+91 80 4718 2200", "phoneNumberId": "123456789012345", "conversationId": "0192f1…", "session": { "sdpType": "offer", "sdp": "v=0\r\no=- …" } }}Two constraints that will catch you out otherwise.
Answer the webhook within 5 seconds. Return 200 and do the work afterwards. A
call forward is not queued and not retried, because a retry is no use to something
that has stopped ringing. Building a peer connection before you reply will time the
forward out on a call you then answer perfectly well.
The offer dies with the ring. Meta holds a call open for 30–60 seconds. If you
lost the payload, GET /calls/:id/offer returns a live one while there still is
one. answerable: false means there is not — a state, not an error.
Answering
Section titled “Answering”# What to negotiate with. Fetch at start-up, not per call.curl "https://vaarto.com/api/v1/calls/ice" \ -H "Authorization: Bearer $VAARTO_API_KEY"
# Your SDP answer. From here you are the media endpoint.curl -X POST "https://vaarto.com/api/v1/calls/CALL_ID/answer" \ -H "Authorization: Bearer $VAARTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sdp": "v=0\\r\\no=- \u2026" }'// What to negotiate with. Fetch at start-up, not per call.const iceRes = await fetch("https://vaarto.com/api/v1/calls/ice", { headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}` },});
const { data: ice } = await iceRes.json();
// Your SDP answer. From here you are the media endpoint.const answerRes = await fetch("https://vaarto.com/api/v1/calls/CALL_ID/answer", { method: "POST", headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ sdp: "v=0\\r\\no=- \u2026", }),});
const { data: answer } = await answerRes.json();import os, requests
# What to negotiate with. Fetch at start-up, not per call.ice_res = requests.get( "https://vaarto.com/api/v1/calls/ice", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, timeout=10,)
ice = ice_res.json()["data"]
# Your SDP answer. From here you are the media endpoint.answer_res = requests.post( "https://vaarto.com/api/v1/calls/CALL_ID/answer", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, json={ "sdp": "v=0\\r\\no=- \u2026", }, timeout=10,)
answer = answer_res.json()["data"]// What to negotiate with. Fetch at start-up, not per call.$ice = Http::withToken(env('VAARTO_API_KEY')) ->get('https://vaarto.com/api/v1/calls/ice') ->json('data');
// Your SDP answer. From here you are the media endpoint.$answer = Http::withToken(env('VAARTO_API_KEY')) ->post('https://vaarto.com/api/v1/calls/CALL_ID/answer', [ 'sdp' => 'v=0\r\no=- …', ]) ->json('data');We pre-accept at Meta before accepting, which brings the media path up a moment early — without it the first second of audio is clipped.
One call, one answer
Section titled “One call, one answer”A ringing call reaches three places at once: the web console, the mobile app, and you. Whoever answers first wins, and everyone else is told immediately and stops ringing.
Lose that race and you get 409 CALL_ALREADY_ANSWERED. That is not a failure — a
person picked up before your server did. Tear your peer connection down and carry
on. Never retry it.
Your server will usually win, because it answers in milliseconds where a human takes
seconds. If you would rather people answered, do not grant CALLS_WRITE.
Declining and hanging up
Section titled “Declining and hanging up”curl -X POST "https://vaarto.com/api/v1/calls/CALL_ID/action" \ -H "Authorization: Bearer $VAARTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "action": "reject" }'const res = await fetch("https://vaarto.com/api/v1/calls/CALL_ID/action", { method: "POST", headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ action: "reject", }),});
const { data } = await res.json();import os, requests
res = requests.post( "https://vaarto.com/api/v1/calls/CALL_ID/action", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, json={ "action": "reject", }, timeout=10,)
data = res.json()["data"]$data = Http::withToken(env('VAARTO_API_KEY')) ->post('https://vaarto.com/api/v1/calls/CALL_ID/action', [ 'action' => 'reject', ]) ->json('data');reject declines a call that is still ringing. It takes the same claim answering
does, so it can also come back 409. terminate hangs up a call that has been
answered, and needs no claim.
Placing a call
Section titled “Placing a call”The mirror image: you make the offer.
curl -X POST "https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call" \ -H "Authorization: Bearer $VAARTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sdp": "v=0\\r\\no=- \u2026" }'const res = await fetch("https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call", { method: "POST", headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ sdp: "v=0\\r\\no=- \u2026", }),});
const { data } = await res.json();import os, requests
res = requests.post( "https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, json={ "sdp": "v=0\\r\\no=- \u2026", }, timeout=10,)
data = res.json()["data"]$data = Http::withToken(env('VAARTO_API_KEY')) ->post('https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call', [ 'sdp' => 'v=0\r\no=- …', ]) ->json('data');Meta’s answer does not come back in that response. It arrives on your forward
webhook as the session of a call.accepted event, so your peer connection stays
in have-local-offer until then.
This means forwarding is required to place calls, not just to answer them. A number with no forward endpoint can be called from — the request succeeds and the customer’s phone rings — but Meta’s answer has nowhere to go, so your peer connection never completes and the call rings until it times out. Set the number’s reply mode to Forward before you place anything.
Unlike message.received, the endpoint does not have to subscribe to any event for
this: assigning it to the number is enough.
When they pick up, Meta’s answer reaches your endpoint as call.accepted:
{ "event": "call.accepted", "channel": "WHATSAPP", "call": { "id": "0192f3…", "wacid": "wacid.HBgM…", "direction": "OUTBOUND", "status": "ACCEPTED", "from": "919876543210", "fromName": "Asha Nair", "to": "+91 80 4718 2200", "phoneNumberId": "123456789012345", "conversationId": "0192f1…", "durationSeconds": null, "session": { "sdpType": "answer", "sdp": "v=0\r\no=- …" } }}from is the customer and to is your number here too — the pair names the thread,
not who dialled. direction is what tells you this was yours.
Asking for permission
Section titled “Asking for permission”You cannot call somebody who has not agreed to it. Ask, and read the answer, without leaving the API.
# Where you stand with this person.curl "https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission" \ -H "Authorization: Bearer $VAARTO_API_KEY"// Where you stand with this person.const res = await fetch("https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission", { headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}` },});
const { data } = await res.json();import os, requests
# Where you stand with this person.res = requests.get( "https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, timeout=10,)
data = res.json()["data"]// Where you stand with this person.$data = Http::withToken(env('VAARTO_API_KEY')) ->get('https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission') ->json('data');{ "data": { "status": "temporary", "canCall": true, "expiresAt": "2026-10-16T10:00:00.000Z", "canAsk": false, "limits": [{ "time_period": "PER_DAY", "max_allowed": 1, "current_usage": 1 }] } }status is no_permission, temporary or permanent. Branch on canCall —
it already folds the two that mean yes. expiresAt is set only while a temporary
grant is running.
canAsk is Meta’s own allowance, not ours: the request may be sent once a day and
twice a week per person. Read it before offering the button, so you do not spend an
ask that is going to be refused.
# Ask. `text` is optional — leave it out and we send a plain one.curl -X POST "https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission" \ -H "Authorization: Bearer $VAARTO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Can we call you about order BLR-4471?" }'// Ask. `text` is optional — leave it out and we send a plain one.const res = await fetch("https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission", { method: "POST", headers: { Authorization: `Bearer ${process.env.VAARTO_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "Can we call you about order BLR-4471?", }),});
const { data } = await res.json();import os, requests
# Ask. `text` is optional — leave it out and we send a plain one.res = requests.post( "https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission", headers={"Authorization": f"Bearer {os.environ['VAARTO_API_KEY']}"}, json={ "text": "Can we call you about order BLR-4471?", }, timeout=10,)
data = res.json()["data"]// Ask. `text` is optional — leave it out and we send a plain one.$data = Http::withToken(env('VAARTO_API_KEY')) ->post('https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission', [ 'text' => 'Can we call you about order BLR-4471?', ]) ->json('data');Meta renders it with an Allow button; you supply only the sentence above it. It counts as an ordinary message, so the 24-hour window has to be open — if it is not, the request is refused here rather than at Meta. It is recorded in the thread as an outbound message, so an operator looking at the conversation can see it was asked.
Their answer arrives as an ordinary message.received, with the verdict on
details.callPermission:
{ "messageId": "cmu…", "conversationId": "0192f1…", "text": "Allowed calls", "from": "919876543210", "fromName": "Asha Nair", "to": "+91 80 4718 2200", "details": { "callPermission": { "response": "accept", "granted": true, "isPermanent": false, "expiresAt": "2026-10-16T10:00:00.000Z", "source": "user_action" } } }Branch on granted, never on text — that string is for a person reading the
thread and is not a contract. expiresAt is null on a permanent grant and on a
refusal.
Both routes are on the call scopes: reading needs CALLS_READ, asking needs
CALLS_WRITE.
If you place a call without permission the request is refused with
403 CALL_PERMISSION_REQUIRED, and the conversation id comes back on the error so
you can ask and retry.
Why the SDP comes through us
Section titled “Why the SDP comes through us”Because the Meta credentials are ours to hold, not yours to manage. Your answer is one HTTP request, once per call, carrying one signalling message — it adds nothing measurable to a call, and it is what lets us keep your call log and your billing honest.
The audio never touches our servers either way.