Raven Docs

WebSocket Protocol

For writing a client where Raven doesn't ship an SDK, or for debugging what's on the wire.

You don't need this page to use Raven Chat — @corvidhq/chat speaks this protocol so you don't have to, and the SDK is the supported interface. This is for a client in a language Raven doesn't ship an SDK for, or for reading what's actually on the wire in devtools.

Endpoint

wss://<your-raven-host>/v1/chat/ws?token=<chat token>

Always wss:// in production — plain ws:// is local-development only, since a token in a URL over cleartext is a credential in cleartext. Optional query parameters sdkVersion/platform are recorded for debugging and don't affect behavior.

Authentication

The token goes in the query string because the browser WebSocket API cannot set headers on an upgrade — there's no Authorization header option; every browser WebSocket client has this constraint. That's a real trade-off (URLs end up in proxy logs and browser history), and it's why chat tokens are shaped the way they are:

  • Short-lived (1 hour default, 6 hours maximum, no non-expiring option).
  • Revocable, checked on every connect.
  • Scoped to one user and optionally to specific conversations.

Never send a project API key here — it would be a permanent, project-wide credential sitting in a URL.

Token claims

{
  "jti": "ctk_7Qd2nF...",
  "sub": "user-123",
  "pid": "<project uuid>",
  "cvs": ["<conversation uuid>"],
  "scopes": ["chat:read", "chat:send"],
  "iat": 1787054953,
  "exp": 1787058553,
  "aud": "raven-chat",
  "iss": "raven"
}

aud is fixed at raven-chat — what stops a dashboard session JWT or an RTC token being replayed here even if a key were somehow shared.

Origin

Browsers always send Origin on an upgrade, and page JavaScript can't forge it. Raven checks it against the configured allow-list and rejects a mismatch with close code 4403. A missing Origin is allowed — non-browser clients legitimately don't send one.

Close codes

Application close codes live in 4000–4999 (RFC 6455 §7.4.2), distinct from the RTC signaling plane's — a close code in devtools tells you which plane produced it.

CodeMeaningReconnect?
1000Normal closureNo — intentional
4401Authentication failedNo — retrying a bad token can't help
4403Origin not allowedNo
4429Connection rate limitYes, after backing off
4440Token expiredYes, with a fresh token
4500Server shutting downYes, after backing off — a deploy, not a fault

@corvidhq/chat treats 4401 and 4403 as terminal and reports failed rather than retrying forever.

Frame format

Every frame is a JSON object with a type. Flat fields, no envelope wrapping — readable in devtools without decoding anything. A frame carrying an id gets a correlated ack or error back with the same id, which is how request/response is built on a socket that's otherwise a one-way event stream.

Client → server

TypeFieldsNotes
room.joinroomAuthorization is checked here
room.leaveroom
message.sendroom, text, messageType?, replyTo?, clientMessageId?, attachmentId?, metadata?
message.updatemessageId, textAuthor only
message.deletemessageIdAuthor, or chat:moderate
reaction.add / reaction.removemessageId, emojiIdempotent
typing.start / typing.stoproom
read.markmessageIdMarks this and everything before it
presence.setstatusonline, away, offline
pingApplication-level

Server → client

TypeCarries
connectedconnectionId, userId, scopes, expiresAt, heartbeatIntervalMs
ackid, ok, data
errorid?, code, message, retryAfterSeconds?
room.joinedroom, name, presence[], typing[]
room.leftroom
message / message.updatedmessage
message.deletedmessageId, roomId, deletedAt, deletedBy
reaction.added / reaction.removedmessageId, roomId, userId, emoji, at
typing.started / typing.stoppedroomId, userId
presenceroomId, userId, status, at
readroomId, userId, messageId, at
pongid?

Unknown types must be ignored, not rejected. A newer server may send a frame an older client doesn't know about; throwing on one would break a client on an upgrade it didn't ask for.

A full exchange

→  (upgrade with ?token=…)
←  {"type":"connected","connectionId":"ccn_8Kd…","userId":"alice",
    "scopes":["chat:read","chat:send"],"expiresAt":"2026-08-18T13:19:49Z",
    "heartbeatIntervalMs":25000}

→  {"type":"room.join","id":"j1","room":"conv_9WcQ…"}
←  {"type":"room.joined","id":"j1","room":"conv_9WcQ…","name":"support",
    "presence":[{"userId":"bob","status":"online"}],"typing":[]}

→  {"type":"message.send","id":"m1","room":"conv_9WcQ…",
    "text":"Hello everyone!","clientMessageId":"client_1"}
←  {"type":"ack","id":"m1","ok":true,"data":{
    "status":"stored","deduplicated":false,
    "message":{"id":"msg_3xR…","senderId":"alice","createdAt":"…"}}}
←  {"type":"message","message":{"id":"msg_3xR…", ...}}

The ack is the durability signal, correlated to the request. The message frame is the fan-out, and the sender receives it too — so every participant, sender included, renders the same server-ordered row rather than a locally-guessed one.