@zapo-js/voip is the official calling plugin for zapo-js. It rides on the plugin system, exposes a WaVoipCoordinator at client.voip, and emits voip_* events on the host client.
Calls are bidirectional in both media types: audio (MLow/Opus) and video (H.264). The plugin transports media — it does not encode or decode it. You bring the codec: 16 kHz mono PCM for audio, H.264 Annex-B access units for video.
This page covers 1:1 calls. Since 1.4.0 zapo also places and answers group calls, which share this whole surface and add a roster plus per-participant attribution — see Group calls.
Install
zapo-js is required. The media peers are optional since 1.2.0 — install them when the media runs in this process, which is the default:
With remote media, neither media peer is needed here at all — the media runs on the host that has the microphone.
Video is never encoded or decoded by the plugin, so
feedLiveVideo takes H.264 you produce yourself — from ffmpeg, a hardware encoder, or an already-encoded stream.
Wire the plugin
AddvoipPlugin() to WaClientOptions.plugins. The coordinator appears at client.voip and the voip_* events become available on client.on.
Plugin options
number
default:"1"
Maximum simultaneous non-ended calls (ringing, connecting, or active). Increase to enable parallel multi-call. Outgoing
startCall rejects, and incoming calls arrive with acceptBlocked: true, when the limit is hit.LogLevel
Minimum log level for the (chatty) VoIP plugin. Defaults to the host client’s level; set it to cap diagnostics independently — for example
'warn' to keep VoIP noise out of a trace host logger.boolean
Dial each relay on the port its
<te2> endpoint advertises, instead of on the fixed web-client relay port (3480).The default depends on the session and is resolved per call: true on a companion, false on a mobile primary. The wire never says which port a relay answers on, and measurement settled it — a companion’s legs open only on the advertised port, a primary’s only on 3480. Passing the option explicitly still wins.Either way a leg that does not open within 5s, or opens and hears nothing within 4s, is redialled once on the other port when there is one; an endpoint already advertising 3480 has none. So a wrong first guess costs a few seconds, not the call.boolean
default:"false"
Talk to each relay over a plain connected UDP socket — no ICE, DTLS or SCTP — dialling the port the relay advertises. SRTP and the jitter pipeline are unchanged.
{ mode: 'local' | 'remote' }
default:"{ mode: 'local' }"
Where the call’s media runs.
'local' carries it in this process on @roamhq/wrtc and libmlow-wasm-fork. 'remote' carries none of it here — see Media on another host.boolean | MLowRedOptions
default:"false"
Send MLow RED: each packet carries the low-rate copy of the previous frame, then the frame itself, so a single lost packet can be reconstructed. Turning it on turns DTX off. With remote media, pass it to the host’s
WaCallMediaReceiver instead.Uint8Array | ArrayBuffer | string
Weights of the MLow Companion decoder post-filter — the bytes of
mlow_companion_v1.pte, or a path read on the first call. Not shipped with the package, and it costs about 40× the decode CPU; weights that fail to load leave calls decoding without it. With remote media, pass it to the host instead.boolean
default:"false"
Emit
voip_call_participant_audio: each group-call participant’s decoded audio apart, before the mix. Off by default since every frame is copied for it, and local media only. See Group calls.The coordinator surface
Placing a call
startCall takes a discriminated union: either the 1:1 shape below or the group shape, never a mix. For the group shape see Group calls.
string
required
Bare or device JID to call. Required on a 1:1 call; forbidden alongside
groupJid.boolean
default:"false"
Place a video call. H.264 video media then flows alongside audio: send access units with
feedLiveVideo and receive inbound frames on voip_call_inbound_video. The media type is fixed at offer time — there is no mid-call upgrade from audio to video.string
Audio file to preload and play once the call connects (needs
ffmpeg). Equivalent to calling loadAudio after the call goes active.string[]
Explicit peer device JIDs to ring; omit to resolve them automatically.
startCall resolves with the new call id once the offer is sent. Progress then arrives via voip_call_state. It rejects when you’re at the maxConcurrentCalls limit or if the offer fails to send.
Answering and ending
acceptCall(callId)— accept a ringing incoming call. Throws ifcallIdis unknown or not in an acceptable state.rejectCall(callId, reason?)— reject a ringing incoming call (default reasonDeclined). Sends the reject stanza, then tears the call down.endCall(callId, reason?)— end an active or connecting call (default reasonUserEnded). No-op if the call is unknown or already ended.
leaveCall and joinCall. See Leaving versus ending and Ringing, re-ringing and joining.
Preloaded outbound audio
audioPath via ffmpeg and queues it as the outbound audio, played once the call is active. Throws if the file is missing or ffmpeg is unavailable.
For an unbounded or live source (TTS stream, ongoing capture) use external audio mode instead — see below.
Mute, and the peer’s mic
setMute stops your own capture and announces the new state to the peer on a <mute_v2> stanza, which is what draws a mic-off indicator on the other side. Capture keeps ticking and feeds silence, so the stream and its SSRC stay alive and unmuting takes effect on the next frame. The same state is announced once just after the call goes active, which gives the peer something to render on a call that is never muted.
It stays void at every layer: the stanza leaves fire-and-forget and a failed send is only logged, since the microphone is off either way. A redundant toggle, an inactive call and an unknown callId all send nothing.
The peer’s own announcements land on stateData.peerAudioMuted and fire voip_call_peer_mute:
Muting another participant rides the same stanza under a second attribute (
request-state) and is not implemented: WhatsApp restricts it to group calls, this package only places 1:1 calls, and a client that receives such a request on a 1:1 call drops it. An incoming one is logged and ignored. A <mute_v2> sent by another device of this same account is ignored too.Raise hand
Raising a hand is durable state, not a one-off notification: the peer keeps seeing the hand until it is lowered, and each participant only ever controls its own. Announcing a state already in force sends nothing, in either direction.call.stateData.handRaised; the remote ones are the device JIDs in call.raisedHands, each surviving until that participant lowers the hand or the call ends. The call has to be active. setHandRaised throws on an unknown callId or a failed send.
A raised hand travels as one of two distinct message types, and the peer picks which by a gate this side cannot see: a
<user_action action='raise_hand'> envelope, or an older top-level <raise_hand>. Both are read and both produce the same state and the same event; zapo announces its own hand with the first.Reactions
An emoji reaction is not a call stanza. It rides the call’s own media socket as an RTP packet on the app-data stream, protected with the same per-JID end-to-end key as the audio, so it needs no second transport.sendReaction returns false when nothing went on the wire — the call is not active, or its app-data stream is not open yet. Nothing negotiates the payload type of that stream (each side registers its own and the offer carries none), so a reaction can go out before any inbound packet has arrived.
Live outbound audio (external mode)
For sources you can’t preload — streaming TTS, real-time capture — switch the call into external audio mode and pump samples in as they arrive. The plugin buffers them through a bounded jitter buffer and watermarks the buffer so a respectful producer never loses audio.Video calls
Video rides on the same session as audio, on its own SSRC and RTP stream. It is active only when the call’smediaType is CallMediaType.Video — that is, when you passed isVideo: true to startCall, or when the peer offered a video call you accepted.
number
Feed one complete H.264 Annex-B access unit (
Uint8Array, start-code delimited). timestampUs is the capture timestamp in microseconds; the plugin converts it to the 90 kHz RTP clock. Returns the number of RTP packets sent, or 0 when nothing went out — video media is not active on that call, or your frames are still held.Your first frames are held
WhatsApp Web sets up the stream for your video some time after the accept, and a first packet that lands before that stream exists leaves your video stuck at its key frames for the rest of the call. So zapo holds your frames until the peer is ready:feedLiveVideo returns 0 while held, and the stream opens on the first key frame fed after the hold lifts — so a source with a long key-frame interval adds up to one interval on top.
Receiving video
Two events carry inbound video, and you pick the level you want:The plugin carries video; it does not encode or decode it. Producing H.264 (and displaying what arrives) is your side of the contract —
ffmpeg, a hardware encoder, or an existing stream. There is also no mid-call mute for video: stateData.videoOff reflects what the peer signals, and simply not calling feedLiveVideo stops your outbound stream.Audio to video, mid-call
A voice call becomes a video call without a new offer: both sides negotiate it with<video> stanzas and the call, its transport and its crypto carry on untouched.
It is a handshake, not an announcement. The request leaves as UpgradeRequestV2 (the 1:1 code), no video RTP leaves before the peer accepts, and the wait is bounded by the same five-second guard timer the peer runs.
Answering one the peer asked for:
acceptVideoUpgrade opens the local video sender on a call negotiated as audio — the RTP session and the video SSRCs the relay has to know about, neither of which an audio call has — then announces the camera to the peer as its own <video>. Without that announcement the peer is told the upgrade was accepted and never told the video went live.
cancelVideoUpgrade(callId) withdraws a request this side sent before the peer answers; the pending requestVideoUpgrade then resolves 'cancelled'.
change.state is the raw wire number, which is exactly the ordinal of the exported WA_VIDEO_STATE — there is no separate wire enum and nothing is translated on either side. change also carries transactionId, deviceOrientation, decoderCodec (dec, what the peer can receive), encoderCodec (enc, what its encoder produces) and supportedCodecs (enc_supported, a bitmask; null means omitted, not unsupported).Screen share
A screen share is not a second stream. It travels on the video stream the call already has, on the same SSRCs and the same payload type — so a share is a statement about what the picture is, not a new media path. Nothing on the wire moves when one starts.WA_SCREEN_SHARE_SEND_VERSION (V2): the sharer’s camera is off for as long as the share lasts and the screen takes its place. Stop feeding the camera before starting a share and resume after stopping it. Your own state is call.stateData.screenSharing.
A start or stop carries screen_share_id, counted per call from 1, as the official clients send it.
setScreenShare throws on an unknown callId or a failed send, and — when starting a share only — on a call that carries no video yet (agree an upgrade, or call setVideoEnabled on a group call, first). A failure throws with nothing changed, so a peer that never heard is never believed to be rendering.
Reading the peer’s share:
PeerScreenShare (state, requestState, version, screenWidth, screenHeight, deviceOrientation) is null when the stanza did not carry it, and unknown numbers pass through rather than collapsing into a known name. The last value stays on call.peerScreenShare, so a voip_call_state listener can read it without subscribing separately.
From
WA_SCREEN_SHARE_VERSION.V3 on, a sharer runs screen and camera at once as two streams — but a screen share has no SSRC space of its own, and how a V3 sender lays two streams over the one space is not established. zapo therefore shares at V2 and never announces V3.Snapshots
CallInfo captures everything you need to render UI: callId, peerJid, callCreator, direction, mediaType, createdAt, callerPn, groupJid, plus derived getters isInitiator, isActive, isRinging, isEnded, canAccept, canReject, isAcceptBlocked.
isGroupCall joins the derived getters: true when the call names a group, carries a roster, or the manager marked it as one.
callerPn is the caller’s phone JID, read from the caller_pn attribute of an incoming offer. It’s what lets you match a caller the offer identifies only by LID back to a phone number. It does not change media derivation: the peer SSRC and the SRTP keys stay bound to the LID device JID, which is what the peer derives from.Events
AddvoipPlugin() to plugins and these events become available on client.on. Without the plugin they don’t exist on the type — see Plugins.
voip_call_inbound_audio is paced by the jitter buffer at one 60 ms tick of 960 samples. Concealed loss and buffer underrun arrive as audio and silence respectively, so the stream keeps the call’s timebase; a tick with nothing queued at all is skipped instead. Decoded audio is copied for it only while a listener is attached — including one added after the call started — so leaving it unsubscribed costs nothing.participantJid names the sending device on every event that carries it, so one listener serves 1:1 and group calls alike. It is optional because a 1:1 call has only one peer, and because attribution depends on the media host being able to tell senders apart. The manager-level events (client.voip.on('call_*')) take it as a trailing argument, so listeners written before 1.4.0 keep working.Enums
Allenum values are lowercase strings — use the enum constants to avoid typos.
CallState
CallDirection
CallMediaType
EndCallReason
AcceptedElsewhere / RejectedElsewhere are what you render as “answered on another device” instead of a missed call. An <accept> from another of your devices on a call this one is receiving ends it as AcceptedElsewhere without sending anything, rather than running the outgoing-accept flow and ringing until it times out.WA_VIDEO_STATE
The raw wire numbers a <video> carries — change.state on voip_call_peer_video_state is the ordinal itself.
WA_VIDEO_UPGRADE_TIMEOUT_MS (5000) is the guard timer both sides run.
WA_SCREEN_SHARE_STATE and WA_SCREEN_SHARE_VERSION
WA_SCREEN_SHARE_VERSION runs Invalid: -1, Legacy: 0, V1…V4. It decides whether a share is a second video stream or a replacement for the camera: up to V2 the screen is the call’s one video stream and the camera is forced off; from V3 the sharer runs both at once. Invalid is negative on purpose — the client’s own sentinel for “no version”, not a version that can travel. zapo announces WA_SCREEN_SHARE_SEND_VERSION, which is V2.
Media formats
Audio
- Sample rate: 16 kHz.
- Channels: mono.
- Sample format:
Float32Arrayin[-1.0, 1.0]. - Codec on the wire: WhatsApp’s Opus profile, encoded via
libmlow-wasm. The RTP payload type is120(PayloadType.WhatsAppOpus).
voip_call_inbound_audio) and outbound (feedLiveAudio) are this same Float32Array shape — keep your buffers in this format and you can route them straight through.
Video
- Codec: H.264, Annex-B byte stream (start-code delimited NAL units).
- Unit of exchange: one complete access unit per
feedLiveVideocall, and one pervoip_call_inbound_videoevent. - Clock: 90 kHz RTP. You pass capture timestamps in microseconds; inbound
frame.timestampis already in RTP units. - RTP payload types:
97(PayloadType.WhatsAppH264) and103(PayloadType.WhatsAppH264Rtx, the repair stream).
Worked example
A minimal incoming auto-accept that records inbound PCM into an in-memory buffer:examples/voip-example.ts in the zapo source tree.
See also
Group calls
Placing, answering, joining and leaving a group call, and the per-participant surface.
Media on another host
Run a call’s media in the browser with
@zapo-js/voip-media while signaling stays on the server.Plugin system
How
voipPlugin() plugs into WaClient, and how to author your own.Events
The host client’s event surface;
voip_* events sit alongside the built-ins.Credits
The VoIP plugin was built by:- @vinikjkkj
- @edgardmessias — Edgard Lorraine Messias
- @w3nder — Wender Teixeira
