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. On the RTC
socket a client may list a third value, chatbox.msid-principal, ahead of
chatbox: if the server echoes it as the selected subprotocol, every forwarded track's
msid names the publisher's verified principal_id (see
Who published what). Optional and per socket.
| Close code | Meaning | What to do |
|---|---|---|
4401 | Token expired mid-session. Both sockets. | Mint a fresh token, reconnect, catchup. |
4403 | Membership revoked — the principal was removed from the room. Both sockets; membership is re-checked every 30 s, so it arrives within that of the removal. | Do not reconnect. The token no longer grants this room; drop to your room list. |
1008 | Policy violation — malformed or disallowed frame. RTC socket only. The chat socket answers a bad frame with an error ack and stays open; an oversized frame closes with 1009. | Fix the frame; do not blind-retry. |
1001 | Server shutting down — a deploy. Both sockets, within milliseconds of the signal; the service answers again within a few seconds, and a release restarts production once. | Reconnect with exponential backoff and jitter from about a second, then catchup. On the RTC socket also rebuild the PeerConnection. There is no reconnect rate limit. (Deploys) |
4408 | No response to keepalive. RTC socket only: the server pings an idle socket every 15 s and gives up after 50 s of silence. Browsers answer pings themselves, so a working client never sees it. | Reconnect — the server could not reach you; you were not ejected. |
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; or forward_of (a message id) and nothing else. body.mentions may list up to 50 member principals. |
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) |
broadcast | body (any JSON, ≤ 1 KiB compact), optional to (one member's principal). Ephemeral: never stored, never replayed. 20 a second per socket, a burst of 40 absorbed. Needs chat:send. |
catchup | after_seq, limit |
ping | — answered with pong |
Acknowledgements
Every client frame is answered, correlated by your request_id — except
typing.started, typing.stopped and broadcast, which are
fire-and-forget: nothing on success, an error ack only when refused
(payload_too_large, rate_limited with the seconds to wait,
blocked, forbidden, invalid_request). A refusal never closes
the chat socket.
{ "v": 1, "type": "ack", "request_id": "…", "payload": { … } }
{ "v": 1, "type": "error", "request_id": "…", "error": { "code": "…", "message": "…" } } // client → server: no ack on success, an error ack on refusal
{ "v": 1, "request_id": "…", "type": "broadcast",
"payload": { "body": { "cursor": [120, 44] }, "to": "<principal_id>" } } // "to" optional
// server → every connected member (or the one named in "to"), event_id 0
{ "v": 1, "event_id": 0, "type": "broadcast", "room_id": "…",
"payload": { "from": "<sender>", "body": { "cursor": [120, 44] }, "to": "<receiver>" },
"occurred_at": "…" } 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 / member.updated | No (0) | Delivered reliably while connected; refetch participants after a long absence. member.updated is a role change. |
call.started / call.ended | No (0) | {call_id, initiator}; dedupe on call_id. |
typing.started / typing.stopped / presence.changed | No (0) | Ephemeral by design. |
broadcast | No (0) | {from, body, to?} — a member's ephemeral broadcast, body value-for-value as sent (object keys come back sorted). Your own sockets receive yours too. |
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": "…" }
{ "event": "peers", "peers": [ { "client_id": "13622361611987684004", "principal_id": "u_42" } ] } // once, before any SDP
{ "event": "peer.joined", "client_id": "13622361611987684004", "principal_id": "u_42" } // an upsert
{ "event": "peer.left", "client_id": "13622361611987684004" } // roster hint only 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.
Frame catalogue #
Every frame type with a real payload. 20 of these were recorded off a live socket session; the rest are taken from the JSON-Schema contract and marked, because a two-client session cannot produce them on demand.
Client → server
{
"v": 1,
"request_id": "f7e9cc05-36b6-4afb-88b6-41031a93e877",
"type": "ping"
} {
"v": 1,
"request_id": "a9187c19-350f-4fba-8ae4-a46733b1d922",
"type": "message.send",
"payload": {
"client_message_id": "ws-4e21c7",
"kind": "text",
"body": {
"text": "sent over the socket"
}
}
} {
"v": 1,
"request_id": "a92558a3-9d13-4569-a152-c84abbbf4d90",
"type": "message.edit",
"payload": {
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"body": {
"text": "edited over the socket"
}
}
} {
"v": 1,
"request_id": "1b2bcea6-75c5-4d1e-bb2f-85e89f0807ec",
"type": "reaction.add",
"payload": {
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"emoji": "🎉"
}
} {
"v": 1,
"request_id": "c7e7632b-a53f-4ca6-a2a9-296914f0bf6a",
"type": "receipt.read",
"payload": {
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707"
}
} {
"v": 1,
"request_id": "c9c369e1-4624-4770-aa9e-961d3c2f7f71",
"type": "reaction.remove",
"payload": {
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"emoji": "🎉"
}
} {
"v": 1,
"request_id": "259f9948-457c-4391-ad94-f727c164788e",
"type": "message.delete",
"payload": {
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707"
}
} {
"v": 1,
"request_id": "52f1bac2-ae95-48a8-9018-f048e010391f",
"type": "typing.started"
} {
"v": 1,
"request_id": "8f450377-a0db-41cd-97de-ed1fd510818c",
"type": "typing.stopped"
} {
"v": 1,
"request_id": "a868839f-c7e5-40c4-a837-c16fd039bcbe",
"type": "broadcast",
"payload": {
"body": {
"cursor": [
120,
44
],
"tool": "pen"
},
"to": "bob"
}
} {
"v": 1,
"request_id": "04640a32-398a-41cc-b65b-1c342f26ba6a",
"type": "catchup",
"payload": {
"after_seq": 0,
"limit": 50
}
} Server → client
{
"v": 1,
"event_id": 0,
"type": "presence.changed",
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"payload": {
"online": true,
"principal_id": "bob"
},
"occurred_at": "2026-01-15T09:30:12Z"
} {
"v": 1,
"type": "ack",
"request_id": "f7e9cc05-36b6-4afb-88b6-41031a93e877",
"payload": {
"type": "pong"
}
} {
"event_id": 0,
"occurred_at": "2026-01-15T09:30:13Z",
"payload": {
"attachment_id": "2b575b88-88bb-40d0-b616-57536b782806",
"original_name": "standup.mp4",
"reason": "unrecognized or blocked file type",
"uploader_id": "alice"
},
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"type": "attachment.failed",
"v": 1
} {
"event_id": 5205,
"occurred_at": "2026-01-15T09:30:14Z",
"payload": {
"body": {
"text": "sent over the socket"
},
"client_message_id": "ws-4e21c7",
"created_at": "2026-01-15T09:30:14Z",
"event_seq": 5205,
"id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"kind": "text",
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"sender_id": "alice"
},
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"type": "message.created",
"v": 1
} {
"event_id": 5205,
"occurred_at": "2026-01-15T09:30:16Z",
"payload": {
"body": {
"text": "edited over the socket"
},
"client_message_id": "ws-4e21c7",
"created_at": "2026-01-15T09:30:14Z",
"edited_at": "2026-01-15T09:30:16Z",
"event_seq": 5205,
"id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"kind": "text",
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"sender_id": "alice"
},
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"type": "message.updated",
"v": 1
} {
"event_id": 0,
"occurred_at": "2026-01-15T09:30:19Z",
"payload": {
"emoji": "🎉",
"event_seq": 5205,
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"principal_id": "bob"
},
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"type": "reaction.added",
"v": 1
} {
"event_id": 5205,
"occurred_at": "2026-01-15T09:30:25Z",
"payload": {
"event_seq": 5205,
"id": "4623daf9-3ddf-4801-bf57-c012929b0707"
},
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"type": "message.deleted",
"v": 1
} {
"event_id": 0,
"occurred_at": "2026-01-15T09:30:33Z",
"payload": {
"call_id": "aee47cf4-5330-4071-9061-7a37610c4497",
"initiator": "alice"
},
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"type": "call.started",
"v": 1
} {
"event_id": 0,
"occurred_at": "2026-01-15T09:30:37Z",
"payload": {
"call_id": "aee47cf4-5330-4071-9061-7a37610c4497",
"initiator": "alice"
},
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"type": "call.ended",
"v": 1
} {
"v": 1,
"event_id": 0,
"type": "receipt.delivered",
"room_id": "{room_id}",
"payload": {
"principal_id": "bob",
"message_id": "{message_id}",
"seq": 12
},
"occurred_at": "2026-01-15T09:30:12Z"
} {
"v": 1,
"event_id": 0,
"type": "member.added",
"room_id": "{room_id}",
"payload": {
"principal_id": "carol",
"role": "member"
},
"occurred_at": "2026-01-15T09:30:14Z"
} {
"v": 1,
"event_id": 0,
"type": "member.removed",
"room_id": "{room_id}",
"payload": {
"principal_id": "carol"
},
"occurred_at": "2026-01-15T09:30:20Z"
} {
"v": 1,
"event_id": 41,
"type": "attachment.ready",
"room_id": "{room_id}",
"payload": {
"attachment_id": "{attachment_id}",
"mime": "image/jpeg",
"byte_size": 528,
"has_thumbnail": true
},
"occurred_at": "2026-01-15T09:30:22Z"
} {
"v": 1,
"event_id": 0,
"type": "resync",
"room_id": "{room_id}",
"payload": {
"reason": "gap_detected",
"from_seq": 118
},
"occurred_at": "2026-01-15T09:30:30Z"
} {
"v": 1,
"event_id": 0,
"type": "pong",
"payload": {}
} RTC signalling
sdp as an object; the server flattens it to a plain string on the frame itself. A
candidate is a standard RTCIceCandidateInit — camelCase sdpMid and
sdpMLineIndex, not snake_case.
rtc:offer {
"cmd": "offer",
"sdp": {
"type": "offer",
"sdp": "v=0\r\no=- 0 0 IN IP4 203.0.113.10\r\n…"
}
} rtc:candidate {
"cmd": "candidate",
"candidate": {
"candidate": "candidate:1 1 UDP 2130706431 203.0.113.10 54321 typ host",
"sdpMid": "0",
"sdpMLineIndex": 0
}
} rtc:leave {
"cmd": "leave"
} rtc:answer {
"type": "answer",
"sdp": "v=0\r\no=- 0 0 IN IP4 203.0.113.10\r\n…"
} rtc:offer(server) {
"type": "offer",
"sdp": "v=0\r\no=- 0 0 IN IP4 203.0.113.10\r\n…",
"request_id": 7
} Practical notes #
- Frames are JSON text. Keep them small: a frame over 256 KiB closes the chat socket, a
message body is capped at 16 KiB and a
broadcastbody at 1 KiB. - Reconnect with jittered exponential backoff; on every reconnect,
catchup. There is no reconnect rate limit on either socket. - 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.