TL;DR: Subscribe to calls.post, return HTTP 200 within two seconds, and drive CRM logic from flat JSON statuses (initiated, ringing, answered, missed, canceled). Dedupe on id plus timestamp. You get the full observable lifecycle without WebRTC; call duration waits on a future terminate webhook.
Whapi calls.post delivers a complete observable call lifecycle through flat JSON webhooks for both 1:1 and group calls, without WebRTC or SDP. Your Node.js backend reacts to every ring and answer from a normal HTTPS handler. The hard limit is honest: this API surface has no terminate event, so call duration is unavailable today.
Teams that wire message webhooks first and skip call events lose CRM visibility the moment voice lands in WhatsApp Web.
WhatsApp Group Calls in Web -- Why Call Webhooks Matter Now
Group voice and video on WhatsApp Web is rolling out through beta channels, with reports of up to 32 participants, selective ringing, and shareable call links.
WABetaInfo tracked the Web group-call rollout as a client feature. Backend systems still need WhatsApp group calls on Web need server-side status: who rang, who picked up, who missed, delivered to an endpoint you control. That is what calls.post is for: observable signaling events, not media streams.
A clinic team we worked with wired missed events to a text-back workflow within a day once calls.post was enabled, with no WebRTC stack.
Whapi calls.post in 60 Seconds
Enable calls.post on your channel webhook URL, accept POST payloads, answer fast, and persist each call row before side effects run.
Flat JSON webhooks, not WebRTC SDP. Each delivery wraps one or more objects under a top-level calls array. Fields map directly from the OpenAPI CallEvent schema: no session description, no ICE candidates, no media negotiation on your server.
Typical envelope shape:
{
"event": { "type": "calls", "event": "post" },
"channel_id": "YOUR-CHANNEL-ID",
"calls": [
{
"id": "3EB0C767F26DEECBBE",
"chat_id": "[email protected]",
"status": "ringing",
"from": "[email protected]",
"timestamp": 1721641200,
"group_call": false,
"video_call": true,
"offline_call": false,
"latency": 842
}
]
}
Point the URL in channel settings, subscribe to call events, and test from a linked device. See the incoming webhook format reference for shared envelope fields. Non-200 responses trigger retries.
Call Lifecycle: initiated → ringing → answered / missed / canceled
Initiated, ringing, answered, missed, canceled: observable without media. OpenAPI lists all five statuses on CallEvent.status. Your handler should treat each POST as a state transition, not a standalone notification.
Status: initiated
Fires when the call object is created, before the callee's device rings. Use it to reserve a CRM row or increment attempt counters.
{
"calls": [{
"id": "3EB0A1B2C3D4E5F6",
"chat_id": "[email protected]",
"status": "initiated",
"from": "[email protected]",
"timestamp": 1721641180,
"group_call": false,
"video_call": false,
"offline_call": false,
"latency": 120
}]
}
Status: ringing
Signals active ring on the callee side. Pair with initiated to measure ring latency or trigger screen-pop for agents.
{
"calls": [{
"id": "3EB0A1B2C3D4E5F6",
"chat_id": "[email protected]",
"status": "ringing",
"from": "[email protected]",
"timestamp": 1721641184,
"group_call": false,
"video_call": false,
"offline_call": false,
"latency": 310
}]
}
Status: answered
Terminal success path. Stop retry timers, mark the conversation active, and hand off to whatever voice path your product uses outside this webhook.
{
"calls": [{
"id": "3EB0A1B2C3D4E5F6",
"chat_id": "[email protected]",
"status": "answered",
"from": "[email protected]",
"timestamp": 1721641192,
"group_call": false,
"video_call": true,
"offline_call": false,
"latency": 905
}]
}
Status: missed
No pickup before timeout. Missed-call webhooks trigger CRM text-back flows: follow-up messages, ticket creation, or callback queue insertion.
{
"calls": [{
"id": "3EB0A1B2C3D4E5F6",
"chat_id": "[email protected]",
"status": "missed",
"from": "[email protected]",
"timestamp": 1721641240,
"group_call": false,
"video_call": false,
"offline_call": true,
"latency": 2100
}]
}
canceled also appears in OpenAPI when the caller hangs up before answer or the invite is withdrawn. Older help-desk examples sometimes list four statuses only; trust the schema and handle canceled as its own terminal branch in your state machine.
Group Call Webhooks: 1:1 vs Group Payload Differences
group_call:true changes payload shape at the boolean level. The same status enum applies, but chat_id points at a group JID and routing logic must fan out to multiple agents.
Example derived from the OpenAPI schema (field values illustrative until your channel logs a live group call). For outbound group messaging patterns, see the WhatsApp Groups API overview.
{
"calls": [{
"id": "3EB0GROUPCALL001",
"chat_id": "[email protected]",
"status": "ringing",
"from": "[email protected]",
"timestamp": 1721641300,
"group_call": true,
"video_call": true,
"offline_call": false,
"latency": 640
}]
}
| Field | 1:1 call (group_call: false) |
Group call (group_call: true) |
|---|---|---|
chat_id |
Individual JID (@s.whatsapp.net) |
Group JID (@g.us) |
from |
Caller contact ID | Caller contact ID (same field) |
status enum |
initiated → ringing → answered / missed / canceled | Same enum; selective ring may yield multiple ringing posts |
video_call |
Voice vs video for the invite | Same; group video on Web uses the same flag |
| CRM routing | Map chat_id to one owner |
Map group membership or shared queue; avoid duplicate text-backs per participant |
Whapi vs Official Cloud API: Status Mapping
In the official WhatsApp Business API, Cloud API calling tutorials center on WebRTC connect flows, SDP exchange, and terminate payloads with duration. In Whapi.Cloud, you get signaling-only webhooks on web-session sockets without pushing media negotiation onto your server.
Whapi maps ringing and answered; Meta uses different status names in its VoIP webhook model. Meta Terminate exposes duration; Whapi has no terminate event. Plan billing and wrap-up logic accordingly. For a broader official-vs-unofficial cost framing, see why the official API often misses small-team needs.
| Meta Cloud API call status (reference) | Whapi calls.post status |
Notes |
|---|---|---|
| Connect / RINGING | ringing |
Both signal active ring; naming differs only |
| ACCEPTED | answered |
Callee picked up; media stays client-side on Whapi |
| REJECTED | missed or canceled |
Split explicit reject vs timeout in Whapi enum |
| Terminate (includes duration) | not available | No call-end webhook on this surface today |
| -- | initiated |
Extra early signal before ring; useful for CRM prep |
Expecting Terminate-style duration from calls.post alone is an API coverage gap, not a webhook misconfiguration.
Building a Call Event Handler: State Machine Pattern
Model each call.id as a row in a call state ledger. Only advance when the incoming status beats the stored rank and the timestamp is newer.
Answer webhooks fast with 200; dedupe by call id and timestamp. Queue CRM side effects asynchronously. The same event-reaction pattern applies here: receive, persist, react. Slow handlers cause retries and duplicate rows.
// Returning 500 here retries the webhook and can double-send CRM text-backs
app.post('/webhooks/whapi', express.json(), async (req, res) => {
res.sendStatus(200); // ACK first; process after response
for (const call of req.body.calls ?? []) {
const key = `${call.id}:${call.status}:${call.timestamp}`;
if (await seen(key)) continue;
const prev = await db.getCall(call.id);
const next = rank(call.status); // initiated<ringing<answered|missed|canceled
if (prev && (next < prev.rank || call.timestamp < prev.timestamp)) continue;
await db.upsertCall({ ...call, rank: next });
if (call.status === 'missed') {
await queueTextBack(call.chat_id, call.from);
}
}
});
Pseudocode for the transition guard:
STATE ranks: initiated=1, ringing=2, answered=3, missed=3, canceled=3
ON calls.post:
ACK 200 immediately
FOR each call in payload:
IF dedupe_key(id, status, timestamp) exists: SKIP
IF stored.rank > new.rank: SKIP
IF stored.timestamp > call.timestamp: SKIP
UPSERT call row
IF status == missed AND policy allows: ENQUEUE text-back job
IF status == answered: CANCEL pending retry timers
Processing before the 200 ACK often duplicates missed-call side effects on retry. Whapi uses web-session sockets, so channels stay stable enough to automate call events. See the Node.js WhatsApp bot tutorial for a full webhook starter.
What calls.post Cannot Tell You
Call duration is not available on this webhook surface. Whapi support confirms there is no terminate or call-end event to compute seconds on the line.
Meta Terminate exposes duration; Whapi has no terminate event. Do not infer talk time from answered timestamps. Meta App Dashboard provisioning and SDP debugging belong to Cloud API VoIP tutorials, not Whapi channel settings.
Payload Fields: video_call, offline_call, latency
video_call, offline_call, latency explain delivery context, not media. They do not replace a recordings API or stream metadata.
video_call marks voice vs video invites; offline_call flags offline delivery; latency reports signaling milliseconds (not RTP quality).
Combine the three flags with status when prioritizing agent callbacks: a missed video_call with high latency and offline_call: true often means the callee never saw the ring on Web.
What's Next: Call Initiation on Whapi
Today you observe calls from linked WhatsApp clients; programmatic outbound calling and call-end webhooks are on the near-term roadmap.
POST /calls and call-end webhooks on roadmap. The create-call endpoint already exists for event creation; expect richer initiation and teardown surfaces as group Web calling matures.
# POST https://gate.whapi.cloud/calls - create call event (start_time required today)
curl -X POST "https://gate.whapi.cloud/calls" \
-H "Authorization: Bearer $WHAPI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"start_time":"1721641200"}'
Whapi is actively extending call APIs as Web clients gain group voice and video. Subscribe to calls.post, build the state ledger, and plug in duration when terminate events ship. Track releases on the product changelog. The full observable lifecycle already lands as flat JSON for 1:1 and group calls; duration waits on the next webhook type.









