WebSocket protocol
Two channels: /v1/chat/ws for messaging and /v1/rtc/ws for call
signalling. Both authenticate the same way and both are long-lived.
Authentication #
Sec-WebSocket-Protocol: chatbox, bearer.<access_token>
Two subprotocol values: the literal chatbox, and bearer. followed by the
session token. The token is never accepted in the query string — that would leak
it into logs and referrers. A token that is invalid or lacks the room fails the upgrade.
| Close code | Meaning | What to do |
|---|---|---|
4401 | Token expired mid-session | Mint a fresh token, reconnect, catchup. |
1008 | Policy violation — malformed or disallowed frame | Fix the frame; do not blind-retry. |
1001 | Server shutting down | Reconnect with backoff. |
Chat channel #
Client → server
{
"v": 1,
"request_id": "<uuid you generate>",
"type": "message.send",
"payload": { … }
} type | payload |
|---|---|
message.send | client_message_id, body, optional kind, reply_to, attachment_ids |
message.edit | message_id, body |
message.delete | message_id |
receipt.delivered | message_id |
receipt.read | message_id — advances the read cursor |
reaction.add / reaction.remove | message_id, emoji |
typing.started / typing.stopped | — (ephemeral) |
catchup | after_seq, limit |
ping | — answered with pong |
Acknowledgements
Every client frame is answered, correlated by your request_id:
{ "v": 1, "type": "ack", "request_id": "…", "payload": { … } }
{ "v": 1, "type": "error", "request_id": "…", "error": { "code": "…", "message": "…" } } Server → client
{
"v": 1,
"event_id": 4131, // per-room sequence; 0 = ephemeral, never replayed
"type": "message.created",
"room_id": "0f5c…",
"payload": { … },
"occurred_at": "2026-09-10T11:04:22Z"
} type | Durable? | Notes |
|---|---|---|
message.created / message.updated / message.deleted | Yes | Carries event_id; replayable via catchup. |
attachment.ready / attachment.failed | Yes | Verification finished — render or show the failure. |
receipt.delivered / receipt.read | No (0) | Delivered live; not replayed. |
reaction.added / reaction.removed | No (0) | History carries aggregates instead. |
member.added / member.removed | No (0) | Refetch participants after a long absence. |
call.started / call.ended | No (0) | {call_id, initiator}; dedupe on call_id. |
typing.started / typing.stopped / presence.changed | No (0) | Ephemeral by design. |
resync | — | You fell too far behind: refetch history, reset your cursor. |
pong | — | Answer to ping. |
RTC channel #
A thin wrapper over standard WebRTC signalling. There is no client register frame —
the server registers your connection from the verified token, and a client-supplied identity would
be rejected.
Client → server
{ "cmd": "offer", "sdp": { "type": "offer", "sdp": "v=0…" } }
{ "cmd": "answer", "sdp": { "type": "answer", "sdp": "v=0…" }, "request_id": 7 }
{ "cmd": "candidate", "candidate": { "candidate": "candidate:…", "sdpMid": "0", "sdpMLineIndex": 0 } }
{ "cmd": "leave" } Server → client
{ "type": "answer", "sdp": "v=0…" } // answer to your offer
{ "type": "offer", "sdp": "v=0…", "request_id": 7 } // SFU re-offer — echo request_id
{ "event": "joined" } // status
{ "event": "error", "reason": "…" } Echo
request_id when answering a re-offer. Without it the answer is not
matched to the negotiation and the new participant's media never arrives. This is the single most
common calling bug.
Practical notes #
- Frames are JSON text. Keep them small — there is a per-frame size cap.
- Reconnect with jittered exponential backoff; on every reconnect,
catchup. - Persist your highest processed
event_idper room, not globally. - Send
pingperiodically on idle connections so intermediaries do not reap them. - A slow consumer is flagged for resync rather than silently dropping events.
- The machine-readable contract is the AsyncAPI document plus a JSON-Schema bundle, kept in lockstep with the server by a parity check that fails the build.