media: { mode: 'remote' } splits the two. Signaling stays on the server; the media runs wherever the audio is, on @zapo-js/voip-media — a package with no dependency on zapo-js and no dependency on Node, which runs in a browser as-is.
No media flows through your server. The browser talks to WhatsApp’s relays directly; the server only hands it the plan to do so. It therefore needs neither
@roamhq/wrtc nor libmlow-wasm-fork.Install
On the server, nothing new — just drop the media peers:@zapo-js/voip-media is already a dependency of @zapo-js/voip, so the server has it transitively; you install it in the browser bundle as a direct dependency. Both of its own peers are optional:
The split
What stays on the server, and what moves:
Because of that split, these throw or go quiet on the server with remote media:
Everything else about the call — accepting, ending, state events, mute, hands, screen share, upgrades — stays exactly where it was.
Wiring the server side
Two lines connect the plugin to whatever transport you run between server and browser:A host that joins late
A browser that connects after the call started — a reload, a second tab taking over — has missed every message so far.snapshot hands it the whole plan as it stands:
null for an unknown call, or when media is local. The host also asks for this by itself — see resync.
client.voip.media
The wire protocol
Two message types travel, both plain JSON with a version field. zapo gives you the codecs; the transport is yours — a WebSocket, an SSE stream plus a POST route, a message queue, anything ordered-ish.Server → host: WaCallMediaMessage
keys (SRTP material), relays (endpoints and credentials), ssrcs, settings and video. A full message holds all of it; the others hold only the sections that changed.
Host → server: WaCallMediaEventMessage
Encoding
encode* returns a string and each decode* takes one. JSON has no binary type and no bigint, so the encoding tags both: byte arrays travel under $bytes (base64) and a reaction’s uint64 transaction id under $bigint. Always go through these functions — a hand-rolled JSON.stringify loses the SRTP keys and the transaction id.
WA_CALL_MEDIA_WIRE_VERSION is 1. Only a breaking change moves it; a new field is optional.
Ordering, gaps and resync
WaCallMediaReceiver applies messages one at a time, in call order, and keeps the last seq it accepted:
- A message older than the last accepted one is dropped.
- A gap (
seq > lastSeq + 1) emits aresyncevent carryinglastSeq. Answer it withsnapshot(callId). - A
fullmessage is accepted whenever itsseqis not behind, which is what makes the snapshot answer work. - A message whose apply rejects is not counted as received, so the next one finds the gap and asks for a resync by itself.
resync with a snapshot.
The browser
Audio
The minimum viable call. Build the receiver, start listening, then open the audio from a user gesture:<call> stanza, and only the server speaks the protocol. The click that answers asks the server for it and opens the audio locally; the two happen together.
WaWebCallAudio
WaWebCallAudio.start(sink, options?) carries audio between the browser’s microphone and speaker and a sink — receiver.plane satisfies that sink. The audio device’s clock paces both directions, so a throttled background tab does not starve the call.
MediaStream
Microphone to capture. Default:
getUserMedia with echo cancellation, noise suppression and auto gain — the codec does none of those, they come from the browser. A stream you pass in is not stopped by stop().AudioContext
Context to run in. Default: a new 16 kHz
AudioContext, falling back to the device rate if the browser refuses. A context you pass in is neither closed nor resumed by stop().string
URL of the worklet module. Default: a Blob URL. Under a CSP that forbids
blob:, serve the exported WA_CALL_AUDIO_WORKLET_SOURCE as a file and pass its URL here.stop() releases only what the adapter acquired, and a repeat call returns the same promise.
Firefox refuses a microphone at a foreign sample rate. The adapter handles it: if it owns the context and the 16 kHz rate is refused, it reopens at the device rate and retries.
Video
Build the receiver withonInboundVideo for the peer’s frames, and hand the plane a camera track for yours:
WaWebCallVideoSender.start throws when the browser has no VideoEncoder, or when you hand it a track that is not video.
WaWebCallVideoSender options
sender.stats reports framesCaptured, framesEncoded, framesSent, framesDropped, packetsSent, keyFrames, width, height.
WaWebCallVideoReceiver options
receiver.stats reports framesReceived, framesDecoded, framesDropped (before the first key frame, or after a failure), decodeErrors, and codec — read off the sequence parameter set of the stream decoded last.
webMediaHost
Spreading ...webMediaHost into the receiver options supplies the two platform primitives the media plane needs in a browser:
crypto—webCrypto, built on the browser’s owncrypto.subtle.createPeerConnection—createBrowserPeerConnection, the browser’s ownRTCPeerConnection. A configuration the browser refuses rejects the promise.
useRawUdpTransport has no meaning there.
WaCallMediaReceiver
Options extend the plane’s own, plus:
string
required
The call this receiver serves. A message for a different call id rejects.
(message: WaCallMediaEventMessage) => void
required
Hands an event back to signaling, over whatever transport you run.
(reaction: WaCallReaction) => void
Also told of the reactions that go to signaling, for a host that renders them itself.
(frame: InboundVideoFrame) => void
One reassembled H.264 access unit of a peer video stream, keyed by
frame.ssrc.(packet: InboundVideoRtpPacket) => void
Each decrypted inbound video RTP packet, before reassembly.
Logger
Optional.
createNoopLogger() is exported for when you want the shape without the output.onActive and onRelayLost are not yours to set — the receiver owns them and turns them into the active and relay_lost events it sends back to signaling.Call statistics
stop() returns WaCallMediaStats, which is the thing to log when a call sounded wrong:
Media on a second Node process
The browser is the common case, but nothing about the split is browser-specific. A second Node process is the same wiring with the Node host and the Node peers installed there:nodeMediaHost supplies nodeCrypto (node:crypto) and createWrtcPeerConnection (@roamhq/wrtc). That host needs @roamhq/wrtc and libmlow-wasm-fork, exactly as a local-media server would — the dependencies follow the media, not the signaling.
Raw UDP legs stay off there too; a host that wants them passes the leg factory explicitly:
useRawUdpTransport before you do — it is an experiment, not a tuning knob.
See also
Calls (VoIP)
The signaling side: placing and answering calls, mute, hands, screen share, video upgrades.
Plugin system
How
voipPlugin() plugs into WaClient.