Reference

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 codeMeaningWhat to do
4401Token expired mid-session. Both sockets.Mint a fresh token, reconnect, catchup.
4403Membership 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.
1008Policy 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.
1001Server 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)
4408No 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": { … }
}
typepayload
message.sendclient_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.editmessage_id, body
message.deletemessage_id
receipt.deliveredmessage_id
receipt.readmessage_id — advances the read cursor
reaction.add / reaction.removemessage_id, emoji
typing.started / typing.stopped— (ephemeral)
broadcastbody (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.
catchupafter_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"
}
typeDurable?Notes
message.created / message.updated / message.deletedYesCarries event_id; replayable via catchup.
attachment.ready / attachment.failedYesVerification finished — render or show the failure.
receipt.delivered / receipt.readNo (0)Delivered live; not replayed.
reaction.added / reaction.removedNo (0)History carries aggregates instead.
member.added / member.removed / member.updatedNo (0)Delivered reliably while connected; refetch participants after a long absence. member.updated is a role change.
call.started / call.endedNo (0){call_id, initiator}; dedupe on call_id.
typing.started / typing.stopped / presence.changedNo (0)Ephemeral by design.
broadcastNo (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
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.

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

SEND ping #

captured live

{
  "v": 1,
  "request_id": "f7e9cc05-36b6-4afb-88b6-41031a93e877",
  "type": "ping"
}
SEND message.send #

captured live

{
  "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"
    }
  }
}
SEND message.edit #

captured live

{
  "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"
    }
  }
}
SEND reaction.add #

captured live

{
  "v": 1,
  "request_id": "1b2bcea6-75c5-4d1e-bb2f-85e89f0807ec",
  "type": "reaction.add",
  "payload": {
    "message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
    "emoji": "🎉"
  }
}
SEND receipt.read #

captured live

{
  "v": 1,
  "request_id": "c7e7632b-a53f-4ca6-a2a9-296914f0bf6a",
  "type": "receipt.read",
  "payload": {
    "message_id": "4623daf9-3ddf-4801-bf57-c012929b0707"
  }
}
SEND reaction.remove #

captured live

{
  "v": 1,
  "request_id": "c9c369e1-4624-4770-aa9e-961d3c2f7f71",
  "type": "reaction.remove",
  "payload": {
    "message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
    "emoji": "🎉"
  }
}
SEND message.delete #

captured live

{
  "v": 1,
  "request_id": "259f9948-457c-4391-ad94-f727c164788e",
  "type": "message.delete",
  "payload": {
    "message_id": "4623daf9-3ddf-4801-bf57-c012929b0707"
  }
}
SEND typing.started #

captured live

{
  "v": 1,
  "request_id": "52f1bac2-ae95-48a8-9018-f048e010391f",
  "type": "typing.started"
}
SEND typing.stopped #

captured live

{
  "v": 1,
  "request_id": "8f450377-a0db-41cd-97de-ed1fd510818c",
  "type": "typing.stopped"
}
SEND broadcast #

captured live

{
  "v": 1,
  "request_id": "a868839f-c7e5-40c4-a837-c16fd039bcbe",
  "type": "broadcast",
  "payload": {
    "body": {
      "cursor": [
        120,
        44
      ],
      "tool": "pen"
    },
    "to": "bob"
  }
}
SEND catchup #

captured live

{
  "v": 1,
  "request_id": "04640a32-398a-41cc-b65b-1c342f26ba6a",
  "type": "catchup",
  "payload": {
    "after_seq": 0,
    "limit": 50
  }
}

Server → client

RECV presence.changed #

captured live

{
  "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"
}
RECV ack #

captured live

{
  "v": 1,
  "type": "ack",
  "request_id": "f7e9cc05-36b6-4afb-88b6-41031a93e877",
  "payload": {
    "type": "pong"
  }
}
RECV attachment.failed #

captured live

{
  "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
}
RECV message.created #

captured live

{
  "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
}
RECV message.updated #

captured live

{
  "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
}
RECV reaction.added #

captured live

{
  "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
}
RECV message.deleted #

captured live

{
  "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
}
RECV call.started #

captured live

{
  "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
}
RECV call.ended #

captured live

{
  "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
}
RECV receipt.delivered #

from schema

{
  "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"
}
RECV member.added #

from schema

{
  "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"
}
RECV member.removed #

from schema

{
  "v": 1,
  "event_id": 0,
  "type": "member.removed",
  "room_id": "{room_id}",
  "payload": {
    "principal_id": "carol"
  },
  "occurred_at": "2026-01-15T09:30:20Z"
}
RECV attachment.ready #

from schema

{
  "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"
}
RECV resync #

from schema

{
  "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"
}
RECV pong #

from schema

{
  "v": 1,
  "event_id": 0,
  "type": "pong",
  "payload": {}
}

RTC signalling

The two directions are not symmetric. A client frame nests SDP under 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.
SEND rtc:offer

from schema

{
  "cmd": "offer",
  "sdp": {
    "type": "offer",
    "sdp": "v=0\r\no=- 0 0 IN IP4 203.0.113.10\r\n…"
  }
}
SEND rtc:candidate

from schema

{
  "cmd": "candidate",
  "candidate": {
    "candidate": "candidate:1 1 UDP 2130706431 203.0.113.10 54321 typ host",
    "sdpMid": "0",
    "sdpMLineIndex": 0
  }
}
SEND rtc:leave

from schema

{
  "cmd": "leave"
}
RECV rtc:answer

from schema

{
  "type": "answer",
  "sdp": "v=0\r\no=- 0 0 IN IP4 203.0.113.10\r\n…"
}
RECV rtc:offer(server)

from schema

{
  "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 broadcast body 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_id per room, not globally.
  • Send ping periodically 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.