<call-id>@call) rather than to a peer, with a roster the server keeps and pushes. Since @zapo-js/voip 1.4.0 zapo can place one, answer one, follow who is on it, and leave it.
Everything on the 1:1 calls page still applies — mute, raise hand, reactions, video, screen share — with one difference that runs through all of it: every remote announcement names the device it came from, because there is more than one peer.
Placing one
startCall takes either a 1:1 shape or a group shape, never both — the two are a discriminated union, so TypeScript refuses a mix.
string
required
The group (
…@g.us) this call belongs to. Its presence is what makes the offer a group offer; peerJid is not read.readonly CallOfferParticipant[]
required
Who to ring, ourselves excluded. Each is
{ jid, devices? }: jid is the account to ring (a LID), and devices are its device JIDs, at most five. Omit devices and they are resolved, falling back to the account’s primary device.isVideo and audioFile work the same as on a 1:1 call. The call connects once the server’s roster lists somebody else connected — not when the offer is acked.
Answering one
An incoming group call arrives onvoip_call_incoming like any other, and acceptCall answers it.
Telling a group call from a 1:1 call
Usecall.isGroupCall. The fields behind it are subtler than they look:
Who is on the call
call.groupComposition holds the roster this client has folded in: one WaCallGroupParticipant per account, plus the transaction-id of the last update applied.
voip_call_participants_changed — from the server’s roster updates, or from one participant hanging up:
diff is a WaGroupCompositionDiff, and its lists do not overlap:
A
<device pid> that changes is not a change of who is on the call, and neither is a roster that arrives stale — an update whose transaction-id is behind the last one applied is discarded rather than folded in.WA_GROUP_REMOVAL_CAUSE distinguishes why somebody is off: absent (left out of a roster, which is always the whole call), departed (terminated / invalid), departed_unexpected (rejected, timedout, cancel_offer) and nacked. WA_CALL_PARTICIPANT_STATE carries the raw roster states — connected, outgoing, receipt, rejected, terminated, timedout, creating, invisible, visible, cancel_offer, invited, invalid.
Leaving versus ending
group_call_ended. Either way the local call is over and voip_call_ended fires.
leaveCallthrows on a 1:1 call.- A call that loses its media path leaves, rather than ending it for the others.
- On this side, one participant’s terminate takes only that participant off an established call. Only
group_call_ended, or the server taking this side off, ends it. - When the last other member leaves, the server ends the call within a fraction of a second — there is no state where this side waits alone.
Staying on the call
Every connected member, this side included, sends the call a heartbeat every 10 seconds for as long as it is on it (heartbeat_interval_s from the server’s <voip_settings> overrides the default).
Members also watch each other’s media. Once none of a member’s media has reached this side for about 20 seconds while others’ still does, this side reports it to the server as invisible (peer_state) — the official clients do the same after about 30. The server then takes that member off the call.
When that member is this side, the call goes on without it:
voip_call_ended fires with EndCallReason.Invisible, nothing is sent, and the call can be rejoined with joinCall.Ringing, re-ringing and joining
A group call this side declined, let ring out, left, or was taken off as invisible can be joined while it goes on, under the same call id.joinCall sends one accept to the call itself, as the official client’s join does, and the call then goes through voip_call_state like an accepted one: the server answers with the relay and asks this side to key the call.
It throws when no such call is known here, when this side is on it already, or at the maxConcurrentCalls limit. It only knows calls whose offer reached this side, and forgets one when the server sends group_call_ended, when it is joined, or when a new ring for it arrives.
Two server-driven endings to expect while ringing:
In-call features
Mute, raise hand, reactions, video and screen share all work. Our own announcements go to the call itself, and the remote ones name their sender.Mute
voip_call_peer_mute follows if it complies. Throws on an unknown callId, a 1:1 call, or a failed send.
A request aimed at this side arrives on voip_call_mute_requested and is never applied — comply with setMute(callId, true) if you want to:
WhatsApp Web refuses to ask a phone to mute, so a mobile account rarely receives one.
Video
Every camera that is on reaches this side with no subscription — one stream per sender, attributed byparticipantJid.
setVideoEnabled throws on a 1:1 call, which turns video on through requestVideoUpgrade instead.
On a group call that is audio, requestVideoUpgrade asks the server to turn the whole call into a video call. Every member then sees voip_call_video_upgraded, and nothing is answered: the member who asked has its camera on once the server confirms, and every other camera stays off until its owner calls setVideoEnabled.
void
Choose whose video the call asks the relay for, and at what quality — an array of
CallVideoSubscription ({ participantJid, quality? }, the quality one of WA_VIDEO_QUALITY: Default, Low, Medium, High, Hd). null returns to the default, every participant with the camera on.Not sent to the relay yet: video arrives without it, and a media host that cannot carry it ignores it. Throws on an unknown callId.Screen share
setScreenShare works and is sent to the call. A share start or stop carries screen_share_id, counted per call from 1, as the official clients send it.
Participant media
voip_call_participant_media reports when a participant’s media starts or stops reaching this side — judged locally from the packets received, not from anything the peer announced, and edge-triggered:
participantAudio plugin option, voip_call_participant_audio additionally carries each participant’s decoded audio apart, before the mix that voip_call_inbound_audio carries:
Reactions
Received per sender with no setup;participantJid is set once the media host can attribute them. Clearing a reaction is not reported.
Keys and relays
A group offer carries no key. The call is keyed by theenc_rekey a participant sends once this side joins, answered with a receipt and held when it beats its own offer. When the server makes this side the rekey master, it mints the key and sends it to every connected device.
Each participant’s device is decrypted with its own key. The media plan carries one key per remote device and declares this side’s streams by layer. groupSenderId(callId, deviceJid) computes a sender’s opaque id — its main audio SSRC in hex, stable for the call — which is how the media host names senders.
When the server moves the call to another relay mid-call the media moves with it, and it is redialled when the server re-credits the relay it is on: over raw UDP on the advertised port when the new relay carries no ICE credential, the ordinary way when it does.
Not supported
- Inviting or removing somebody mid-call.
- Group calls on the browser media host (see the warning at the top).
See also
Calls (VoIP)
The 1:1 surface every group call builds on, and the full event and enum reference.
Media on another host
Running call media elsewhere, and the features a host has to declare to carry a group call.
