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.

Close codeMeaningWhat to do
4401Token expired mid-sessionMint a fresh token, reconnect, catchup.
1008Policy violation — malformed or disallowed frameFix the frame; do not blind-retry.
1001Server shutting downReconnect with backoff.

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
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)
catchupafter_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"
}
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.removedNo (0)Refetch participants after a long absence.
call.started / call.endedNo (0){call_id, initiator}; dedupe on call_id.
typing.started / typing.stopped / presence.changedNo (0)Ephemeral by design.
resyncYou fell too far behind: refetch history, reset your cursor.
pongAnswer 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_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.