Skip to content

Messages ​

Both servers speak JSON text frames over a plain WebSocket at /ws. Every message is an object with a type. Unknown types are ignored.

HTTP ​

RequestAnswerUsed for
GET / on the signaling server{"name":"signaling-server","ok":true}The lobby's status dot, polled every 5 s
GET / on the SFU server{"name":"sfu-server","ok":true}Same, in group mode

1:1 calls ​

The signaling server, server.js, default port 4000. It pairs two sockets per room and relays most messages unchanged to the other one.

Client sendsOther client receivesPurpose
join {roomId}peer joined (the peer already in the room)The first joiner creates the room, the second one starts the call
offer {sdp}offer {sdp}SDP offer
answer {sdp}answer {sdp}SDP answer
candidate {candidate: {candidate, sdpMid, sdpMLineIndex}}the sameTrickle ICE
encryption key {key}the same32 bytes of E2EE key material, base64. Sent before the offer.
encryption key receivedthe sameAcknowledgement, only logged
media state {state: {audio, video, screen}}the sameMic and camera on or off, and whether content is shared
leavenothingFrees the seat. Closing the socket does the same.
Server sendsWhen
error {message, fatal}fatal: true ends the call: Room is full, Missing room id. You are already in this room is not fatal.

The server pings every socket every 25 s and drops one that doesn't answer.

Group calls ​

The SFU server, sfu-server, default port 4001 for both TCP and UDP. It doesn't relay: it is the other end of both peer connections.

Client → serverFieldsNotes
joinroomId, name, e2ee, e2eeKey (base64, when e2ee)First message. name is a label such as Web, Android, iOS.
offerpc: "publish", sdpOnce, after joined
answerpc: "subscribe", sdpAnswer to every subscribe offer
candidatepc, candidate: {candidate, sdpMid, sdpMLineIndex}Trickle ICE for either connection
media statestate: {audio, video, screen}Same meaning and same 300 ms rule as in 1:1 calls
chattext
leavenoneThen the client closes the socket
Server → clientFieldsNotes
joinedparticipantId, participants: [{id, name, state}], e2ee, e2eeKeyparticipants lists the others already in the room. e2eeKey is the room key.
answerpc: "publish", sdp
offerpc: "subscribe", sdpInitial and every renegotiation. Answer them one at a time.
participant joinedparticipant: {id, name, state}
participant leftparticipantIdRemove the tile at once
media stateparticipantId, state
chatparticipantId, name, text
errormessage, fatalFatal errors (Room is full, E2EE setting does not match the room) close the socket

The server doesn't trickle its own candidates: they are already in every SDP it sends. It pings every 20 s. A closed WebSocket removes the participant, and there is no session resume.

Forwarded tracks ​

Value
Stream ID (msid)The publisher's participantId
Track ID<participantId>-audio or <participantId>-video
CodecsVP8 and Opus only

Server addresses ​

What each client accepts in the lobby, before it adds /ws:

InputSignaling server, every clientSFU on Android, iOS, macOS, Windows
192.168.1.10http://192.168.1.10, port 80ws://192.168.1.10:4001
192.168.1.10:4000http://192.168.1.10:4000ws://192.168.1.10:4000
https://example.comhttps://example.comwss://example.com
ws://192.168.1.10:4001/wsnot acceptedws://192.168.1.10:4001
Any pathDroppedDropped

So for the signaling server, and for the SFU on the web, include the port. The web client's defaults are port 4000 (signaling) and 4001 (SFU) on the host that serves the page.