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/tokens equivalent). The token travels in the Sec-WebSocket-Protocol upgrade header so it never lands in reverse-proxy access logs or Referer headers.
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.
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.
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 }]). 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.
typing.start / typing.stop
Broadcast to other members of the channel. The originating user never receives their own typing events.
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.
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, RATE_LIMITED, INTERNAL_ERROR.
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 | Auth failure. The SDK should not auto-reconnect; refresh the token first. |
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.
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. Pull missed history via GET /v1/channels/:id/messages?before=<last_seen_id> and merge with live events on reconnect.
Was this page helpful?