WebSocket protocol
Wire-level reference for Cariosan's realtime gateway.
The @cariosan/client SDK shields you from these details, but if you're building a client in a language we don't ship (Swift, Kotlin, Rust), here's the wire protocol in full.
Most readers don't need this page — if you're using @cariosan/client (TypeScript/React Native) or planning to, the SDK handles every frame below. Read this only if you're building a custom client from scratch.
Connection
The JWT comes from IssueUserToken on the Go SDK (or the REST POST /v1/users/:id/token equivalent). The token travels in the Sec-WebSocket-Protocol upgrade header so it never lands in reverse-proxy access logs or Referer headers.
The connection closes with code 4401 when its JWT expires, even if heartbeats
continue. To keep it open, send a fresh token with auth.renew
before then; otherwise obtain a fresh user token through your authenticated
backend and reconnect. A token that is already invalid when you connect is
refused during the upgrade with HTTP 401. Browsers and React Native cannot
read that status and report a close with code 1006, the same as a network
error. Expiry does not implement logout/session revocation. Revoking a user's
tokens through the API closes their open connections with the same code: a revoked
client's next incoming frame is rejected immediately, and an idle or receive-only
connection is closed within about 5 seconds. If the revocation store is briefly
unreachable, open connections are kept for up to 60 seconds before being closed;
new connections fail closed immediately, with 503 and error.code AUTH_UNAVAILABLE
(and a Retry-After header) rather than 401, so a client retries instead of
treating its token as rejected. REST requests made with a user token behave the
same way.
Why the protocol header instead of a query param — query strings get logged everywhere (proxies, browser history, exception reporters). The Sec-WebSocket-Protocol header only appears in the upgrade handshake and is then discarded.
Browsers send this by passing the protocol string as the second argument to new WebSocket(url, "cariosan.jwt.<token>"); curl, wscat, and other CLI clients can set the header directly.
On successful upgrade, the server echoes the same protocol back per RFC 6455 and immediately sends a connection.ready frame.
Frame envelope
Every frame in either direction is JSON with this shape:
data is optional for events that need no payload (ping, pong).
An optional integer v may precede type ({ "v": 2, "type": ... }). It is omitted for v1 (today's protocol) and is only set if a payload ever changes incompatibly. Forward-compatibility contract: a client MUST ignore event types it doesn't recognise and unknown fields within a payload — so new event types and new optional fields never break an older client.
Client → Server events
ping
Heartbeat. Send every 30s. The server closes the connection after 90s of silence.
auth.renew
Hands the open connection a fresh token so it outlives the one it was opened with. No reconnect is needed, so presence does not flap and no frames are missed. The official SDK sends it automatically before the current token expires (see Tokens).
The server verifies the token like a new connection's, including revocation. It accepts the token only if it names the same user in the same workspace and expires no earlier than the current one. The connection then expires when the new token does, and revocation checks run against it. The server answers with auth.renewed, or with an error frame whose client_message_id echoes request_id:
code | Meaning |
|---|---|
UNAUTHORIZED | Invalid token, or a token for another user or workspace |
TOKEN_EXPIRED | The token has already expired |
VALIDATION_ERROR | token missing, or it expires before the current one |
SERVICE_UNAVAILABLE | The revocation check could not run; retry after retry_after_seconds |
A rejected token changes nothing: the connection keeps its current token and still closes with 4401 when that one expires. A server older than this frame answers UNKNOWN_EVENT (without a client_message_id); reconnect with the new token instead.
message.send
Post a message. Equivalent to POST /v1/channels/:id/messages.
The server validates membership, size, attachment URL, and rate limit the same way it does for REST. Success responds with a message.ack; failure responds with an error frame. Both echo client_message_id, so you can match the reply to the frame you sent.
client_message_id is the send's idempotency key when it is a UUID, the same as on REST. Generate one per message and reuse it when you resend after a lost message.ack. The retry then acks the message that is already stored ("replayed": true). Nothing is stored or broadcast a second time, and the retry does not count against the rate limit again. A key is bound to the channel it was first used in; reusing it in another channel returns an IDEMPOTENCY_KEY_REUSED error frame. Any other value, such as "tmp_abc", is only echoed back and gives no protection against duplicates, which was the behaviour before this change.
parent_id is optional — set it to send a quoted reply. The parent must be in the same channel (otherwise a VALIDATION_ERROR frame), and the broadcast message.new embeds a quoted_message preview.
mentions is an optional list of channel members' external_ids to @-mention. Each must be a member (else a VALIDATION_ERROR frame); the broadcast message.new carries the resolved mentions array and each mentioned user receives a notification.mention on their own topic.
channel.read
Marks the channel as read up to now. The server stamps last_read_at and broadcasts a channel.read receipt to other members (subject to the workspace's read-receipts toggle).
channel.delivered
Acks that this client has received the channel's messages (drives ✓✓ grey). The SDK sends this automatically on every inbound message.new; you rarely send it by hand. The server stamps last_delivered_at and broadcasts a channel.delivered receipt.
typing.start / typing.stop
Client debounce responsibility: emit typing.start at most once per 2s, auto-emit typing.stop after 2s idle.
Server → Client events
pong
Reply to ping.
connection.ready
Sent once immediately after upgrade.
message.new
A new message in a channel the user belongs to. Delivered via Redis pub/sub — works across multiple Cariosan server instances.
A reply additionally includes parent_id and a quoted_message preview ({ id, user_id, user_name, content, type, deleted }). A message with @-mentions includes a mentions array ([{ user_id, external_id, name }]). A message that was sent with an idempotency key includes client_message_id (the UUID the sender attached), which the sender's client uses to recognise its own message and retire the matching pending entry; it is not a secret. All of these are omitted when absent — per the forward-compatibility contract, ignore fields you don't recognise.
notification.mention
Delivered to a user's own topic (not the channel topic) when they're @-mentioned, so they're alerted even when not viewing the channel. Drives an in-app badge today; push delivery arrives in a later phase.
message.updated
A message was edited. Broadcast to the channel; the message's content and edited_at change.
message.deleted
A message was unsent. Render it as a tombstone — no content is included.
message.reaction.add / message.reaction.remove
A member added or removed an emoji reaction on a message.
message.ack
Reply to a successful message.send.
A retried send adds "replayed": true. server_message_id and created_at then describe the message stored by the first send, the counterpart of REST's Idempotent-Replayed: true header. The field is omitted on a first send.
auth.renewed
Reply to an accepted auth.renew. expires_at is the connection's new expiry, the new token's exp.
typing.start / typing.stop
Broadcast to other members of the channel. The originating user never receives their own typing events.
channel_id is the channel's server id (UUID), like every other server event; the SDK maps it to your external_id. The client → server frame uses your external_id.
channel.read (receipt)
Broadcast to other members when a member reads the channel, so senders can flip to ✓✓ blue. Origin-filtered (the reader never receives their own). Suppressed when the workspace has read_receipts_enabled = false.
channel.delivered (receipt)
Broadcast to other members when a member's client acks delivery (✓✓ grey). Origin-filtered. Never gated by the read-receipts toggle.
channel.resync
The server detected that realtime events for a channel may have been lost while your socket stayed connected (a full per-channel buffer, or a failed publish to the Redis bus), so your view could be stale even though you never disconnected. Re-read recent history and reconcile it; see Staying in sync. It carries no message content, and it is only sent to members who are currently in the channel.
channel_id is the channel's server id (the SDK maps it to your external_id). reason (inbox_full, publish_failed or authz_unavailable) is diagnostic only: do not branch on it. A burst of losses is coalesced into a leading frame plus at most one trailing frame per second.
presence.update
Sent to every user who shares at least one channel with the subject.
error
Sent when a client frame fails validation or the server can't satisfy it.
Error codes: INVALID_JSON, UNKNOWN_EVENT, VALIDATION_ERROR, NOT_A_MEMBER, CHANNEL_NOT_FOUND, CHANNEL_FROZEN, MUTED, MESSAGE_REJECTED, RATE_LIMITED, QUOTA_EXCEEDED, SERVICE_UNAVAILABLE, IDEMPOTENCY_KEY_REUSED, UNAUTHORIZED, TOKEN_EXPIRED (the last two only answer auth.renew), INTERNAL_ERROR.
Three codes mean "try again later". They carry retry_after_seconds, the WebSocket counterpart of REST's Retry-After header. Wait at least that long before resending the frame:
| Code | Meaning | retry_after_seconds |
|---|---|---|
RATE_LIMITED | You are sending faster than CARIOS_RATE_LIMIT_MESSAGES_PER_MINUTE allows | Until the limit frees up |
QUOTA_EXCEEDED | The workspace's daily quota is used up; details has metric, limit, current and reset_at | Until reset_at; omitted for a quota that does not reset on its own |
SERVICE_UNAVAILABLE | A dependency of the send path (Redis) is unreachable. Nothing was stored. | 5 |
None of them stores the message, so resending the same frame is safe. With a UUID client_message_id it is safe in every case, even when you are unsure whether the first attempt went through.
shutdown
Sent once before the server closes every connection on SIGTERM. Consumers should reconnect after a short delay.
Close codes
| Code | Meaning |
|---|---|
1000 | Normal closure (client or server). |
1001 | Going Away — graceful shutdown. |
4401 | The token expired or was revoked. Do not reconnect with the same token: get a fresh one first. The official SDK does this itself when it has a token provider, and stops with UNAUTHORIZED when it has only a fixed token. |
4400–4499 | Reserved for other auth / policy closes. Same behaviour as 4401. |
Reconnect behaviour
The official SDK auto-reconnects on unexpected closes with exponential backoff (1s → 30s). Rolling your own client should do the same; the server is stateless, so you can reconnect mid-session and resume immediately.
A reconnect with an expired or revoked token fails the upgrade, which a browser reports as close code 1006, indistinguishable from a network failure. Do not retry forever with that token. Check the token's exp, or make one cheap authenticated REST request (a 401 confirms the token is the problem), and get a fresh token before the next attempt. The official SDK does this.
Tip
Watch for the shutdown frame before a disconnect — it lets you delay the reconnect by a few seconds and avoid hammering a server that's still draining.
Catch up on missed messages — if you missed events while disconnected, the WebSocket won't replay them. On every reconnect, and whenever you receive channel.resync, page GET /v1/channels/:id/messages from the newest message back (passing before and before_id from each page's next_cursor / next_cursor_id) until you reach a message you already hold, then merge by message id. This also repairs edits, unsends and reactions that happened while you were away. A complete example is in Staying in sync.
Was this page helpful?