Read this page if you are:
- Writing a client in a language Livqeno has no SDK for, or
- Reading WebSocket messages in your browser's devtools.
What signaling is: before audio and video can flow, the device and the media server must agree on how to connect: which formats to use and which network path works. They agree by exchanging small JSON messages over a WebSocket. That exchange is signaling. The audio and video themselves travel separately, over WebRTC, not over this socket.
Words used on this page:
| Word | Meaning |
|---|---|
| SDP offer / answer | A text description of the media a side wants to send and receive. One side offers, the other answers. |
| ICE candidate | One possible network path (an address and port) to reach a device. |
| Glare | Both sides send an offer at the same time. |
Endpoint
wss://api.livqeno.com/v1/rtc?token=<rtc token>
- Use the
endpointfrom the token grant as the address. It already points at the right host and ends in/v1/rtc. Don't hard-code it. - The token goes in the URL, because browsers can't set headers on a WebSocket connection.
- Always use
wss://in production. Over plainws://, anyone could read the token.
You talk to the server, not to other users
An older version of this protocol sent messages between browsers, with a
targetParticipantId field. Some message names are the same, but their
meaning changed. If a document says the server forwards sdp.offer from
another browser, it's out of date.
Messages
Every message is a JSON object with a type field. All fields sit at the
top level.
Client → server
| Type | Fields | What it does |
|---|---|---|
room.join | roomId?, region?, resume?, autoSubscribe? | Send this first. If you include roomId, it must match the token. After a dropped socket, send resume: { token } with the latest resume token to pick the old session back up. autoSubscribe: false starts every video you receive paused until you ask for a layer (audio always plays); leave it out for the normal behaviour. |
token.refresh | token | Hand the live session a new token (same room and identity) after token.expiring. |
room.leave | — | Leave the room. |
sdp.answer | sdp | Answer the server's offer. (The usual case: the server offers first.) |
sdp.offer | sdp | Your own offer, sent when you start publishing. |
ice.candidate | candidate, sdpMid?, sdpMLineIndex?, usernameFragment? | Send network candidates as you find them. The fields sit flat on the message. |
track.mute | muted, source | Mute or unmute a track you're sending, without removing it. |
track.publish | source | Say what a track is: camera, microphone, or screen share. |
subscription.update | publisherId, trackId, layer | Pick a video quality: low, medium, high, auto, or off to pause that video for you (a hidden tile) without renegotiating. Any other value resumes it. |
ping | — | Keep the connection alive. |
Why track.publish? WebRTC itself can't say whether a track is a camera or
a screen. Without this message, the server has to guess.
Server → client
| Type | What it carries |
|---|---|
room.joined | roomId, current participants and their tracks, and rtcServer / region (the media server's name and region, for support). clientRegion is the region you were placed in (see Regions). e2ee: true appears only in an end-to-end encrypted room: don't publish unencrypted media into it. Also resumeToken, and on a join that asked to resume, resumed: true if the old session was kept (keep your PeerConnection) or false (build a new one) |
room.resume_token | A fresh resumeToken, about once a minute. Keep the latest; each lasts about two minutes |
room.left | roomId, and reason (REMOVED, ROOM_CLOSED, TOKEN_EXPIRED, TOKEN_REVOKED) when the server ended the session. Don't reconnect after REMOVED or ROOM_CLOSED; after TOKEN_EXPIRED or TOKEN_REVOKED, only with a new token |
token.expiring | expiresAt. Sent a minute before the token expires. Reply with token.refresh |
token.refreshed | expiresAt. The new token was accepted. A refused one gets an error with TOKEN_INVALID instead, and the session keeps its old token |
participant.permissions_updated | permissions. What you may now do. Stop publishing anything you lost the right to; the server has already stopped forwarding it |
participant.joined | The person who joined |
participant.left | The person who left |
track.published | Someone started sending a track |
track.unpublished | Someone stopped |
track.muted / track.unmuted | Someone muted or unmuted a track. byServer: true on the copy sent to you about your own track means the server muted it: mute it locally |
sdp.offer | The server's offer. Sent on join, when the room's tracks change, and to restart ICE after your connection has been disconnected for a few seconds. Answer them all the same way |
sdp.answer | The server's answer to your offer |
ice.candidate | Network candidates from the server |
connection.state | The real connection progress (ICE and DTLS) as the server sees it |
speakers.changed | speakers: who is talking, loudest first, up to 8, each with a level from 0 to 1. The whole list each time; empty means nobody. At most every 300 ms |
quality.changed | updates: participantId and quality (excellent, good, poor, lost) for the people whose connection quality changed. Anyone not listed keeps their last score |
subscription.state | publisherId, trackId, state, layer: what you are really receiving of one video. state is active, paused_bandwidth (your bandwidth can't fit it right now) or paused_client (you asked for off). Show quality badges from this, not from the layer you asked for |
publisher.layers | trackId, layers (rid, active): which quality layers of your own video anyone is watching. Turn the others off on your sender to save upload. Ignoring it is safe: you just keep sending every layer |
error | code, message |
pong | Reply to ping |
speakers.changed and quality.changed are about the whole room.
subscription.state and publisher.layers are about you alone. After a
resumed session, you get anything you missed after room.joined, in order.
Older servers never send these four; that's fine, nothing else depends on
them.
The connection flow
Here's a full join, as messages. → is you sending, ← is the server.
→ (upgrade with ?token=…)
→ {"type":"room.join"}
← {"type":"sdp.offer","sdp":"v=0…"}
← {"type":"ice.candidate","candidate":"candidate:… typ host …","sdpMid":"0","sdpMLineIndex":0}
← {"type":"room.joined","roomId":"…","participants":[…],"rtcServer":"sfu-01","region":"ap-south"}
→ {"type":"sdp.answer","sdp":"v=0…"}
→ {"type":"ice.candidate","candidate":"candidate:…","sdpMid":"0","sdpMLineIndex":0}
← {"type":"connection.state","state":"connected"}
Heartbeat
A WebSocket can look open even after the network is gone. Pings let both sides notice a dead connection quickly.
Send ping every | 30 seconds |
| Connection closes after no traffic for | 60 seconds (one missed ping) |
Errors
The error message can have one of 20 codes:
INVALID_TOKEN, TOKEN_EXPIRED, TOKEN_REVOKED, TOKEN_INVALID, UNAUTHORIZED,
ROOM_NOT_FOUND, ROOM_FULL, ROOM_CLOSED, INVALID_MESSAGE,
INVALID_MESSAGE_TYPE, PARTICIPANT_NOT_FOUND, NOT_IN_ROOM,
PERMISSION_DENIED, ORIGIN_NOT_ALLOWED, RATE_LIMITED,
USAGE_LIMIT_EXCEEDED, NO_RTC_CAPACITY, RTC_SERVER_UNREACHABLE,
NEGOTIATION_FAILED, NEGOTIATION_GLARE.
When the server closes the socket, it uses a 4xxx close code:
| Code | Meaning |
|---|---|
1012 | Server restarting. Reconnect within 0–2 s (random), with resume |
4001 | Authentication failed, or the token expired or was revoked mid-session. Get a new token, then reconnect |
4002 | Replaced by a newer session |
4003 | Removed from the room, or the room closed. Don't reconnect |
4004 | Media server lost (reconnect) |
4029 | Rate limited |
More detail: Close codes. Which errors you can retry: Errors.
These codes are separate from the RAVEN_* HTTP error codes. The
socket protocol has its own versions and its own codes.
Limits
| Limit | Default | Variable |
|---|---|---|
| Frame size | 16 KB | SIGNALING_MAX_MESSAGE_BYTES |
| Messages per connection, per window | 100 / 10s | SIGNALING_MAX_MESSAGES_PER_WINDOW |
| Connection attempts per IP, per window | 20 | SIGNALING_MAX_CONNECTIONS_PER_WINDOW |
| Participants per room | 50 | SIGNALING_MAX_PARTICIPANTS_PER_ROOM |
- The message limit is per connection.
- The connection limit is per IP address. It's checked before the token is checked.
You never get a media server address
room.joined tells you the server's name and region, so you can
share them with support. But you always connect to the same endpoint, and
Livqeno picks the media server for you. There's no server address to set up.
Next steps
- Events: the SDK events built on these messages.
- Errors
- Chat WebSocket protocol: the chat socket, which is a different protocol.