Skip to content

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.

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.

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.

Terminal window
# 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"
}'

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.

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.

Terminal window
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"
}'

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.

The mirror image: you make the offer.

Terminal window
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"
}'

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.

You cannot call somebody who has not agreed to it. Ask, and read the answer, without leaving the API.

Terminal window
# Where you stand with this person.
curl "https://vaarto.com/api/v1/conversations/CONVERSATION_ID/call-permission" \
-H "Authorization: Bearer $VAARTO_API_KEY"
{ "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.

Terminal window
# 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?"
}'

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.

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.