Cariosan

WebSocket protocol

Wire-level reference for Cariosan's realtime gateway.

7 min readUpdated Apr 2026

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

wss://chat.example.com/v1/ws
Sec-WebSocket-Protocol: cariosan.jwt.<token>

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:

frame envelope
{ "type": "<event name>", "data": { ... } }

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.

client → ping
{ "type": "ping" }

message.send

Post a message. Equivalent to POST /v1/channels/:id/messages.

client → message.send
{
  "type": "message.send",
  "data": {
    "channel_id": "order_123",
    "content": "Halo",
    "type": "text",
    "attachment_url": null,
    "client_message_id": "tmp_abc",
    "parent_id": "01HX...",
    "mentions": ["user_andi"]
  }
}

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).

client → channel.read
{ "type": "channel.read", "data": { "channel_id": "order_123" } }

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.

client → channel.delivered
{ "type": "channel.delivered", "data": { "channel_id": "order_123" } }

typing.start / typing.stop

client → typing.start / typing.stop
{ "type": "typing.start", "data": { "channel_id": "order_123" } }
{ "type": "typing.stop",  "data": { "channel_id": "order_123" } }

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.

server → pong
{ "type": "pong", "data": {} }

connection.ready

Sent once immediately after upgrade.

server → connection.ready
{
  "type": "connection.ready",
  "data": { "user_id": "uuid", "connected_at": "2026-04-17T10:00:00Z" }
}

message.new

A new message in a channel the user belongs to. Delivered via Redis pub/sub — works across multiple Cariosan server instances.

server → message.new
{
  "type": "message.new",
  "data": {
    "id": "msg-uuid",
    "channel_id": "chan-uuid",
    "user": { "id": "uuid", "external_id": "user_abc", "name": "Alice" },
    "content": "Halo",
    "type": "text",
    "metadata": {},
    "created_at": "2026-04-17T10:00:00Z"
  }
}

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.

server → notification.mention
{
  "type": "notification.mention",
  "data": {
    "channel_id": "chan-uuid",
    "message_id": "msg-uuid",
    "by_user_id": "sender-uuid"
  }
}

message.updated

A message was edited. Broadcast to the channel; the message's content and edited_at change.

server → message.updated
{
  "type": "message.updated",
  "data": {
    "id": "msg-uuid",
    "channel_id": "chan-uuid",
    "content": "edited text",
    "edited_at": "2026-04-17T10:05:00Z"
  }
}

message.deleted

A message was unsent. Render it as a tombstone — no content is included.

server → message.deleted
{
  "type": "message.deleted",
  "data": {
    "id": "msg-uuid",
    "channel_id": "chan-uuid",
    "deleted_at": "2026-04-17T10:06:00Z"
  }
}

message.reaction.add / message.reaction.remove

A member added or removed an emoji reaction on a message.

server → message.reaction.add
{
  "type": "message.reaction.add",
  "data": {
    "message_id": "msg-uuid",
    "channel_id": "chan-uuid",
    "user_id": "uuid",
    "emoji": "👍"
  }
}

message.ack

Reply to a successful message.send.

server → message.ack
{
  "type": "message.ack",
  "data": {
    "client_message_id": "tmp_abc",
    "server_message_id": "msg-uuid",
    "channel_id": "order_123",
    "created_at": "2026-04-17T10:00:00Z"
  }
}

typing.start / typing.stop

Broadcast to other members of the channel. The originating user never receives their own typing events.

server → typing.start (broadcast)
{
  "type": "typing.start",
  "data": { "channel_id": "order_123", "user_id": "uuid" }
}

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.

server → channel.read (receipt)
{
  "type": "channel.read",
  "data": { "channel_id": "chan-uuid", "user_id": "uuid", "read_at": "2026-04-17T10:00:00Z" }
}

channel.delivered (receipt)

Broadcast to other members when a member's client acks delivery (✓✓ grey). Origin-filtered. Never gated by the read-receipts toggle.

server → channel.delivered (receipt)
{
  "type": "channel.delivered",
  "data": { "channel_id": "chan-uuid", "user_id": "uuid", "delivered_at": "2026-04-17T10:00:00Z" }
}

presence.update

Sent to every user who shares at least one channel with the subject.

server → presence.update
{
  "type": "presence.update",
  "data": {
    "user_id": "uuid",
    "status": "online",
    "last_seen_at": "2026-04-17T10:00:00Z"
  }
}

error

Sent when a client frame fails validation or the server can't satisfy it.

server → error
{
  "type": "error",
  "data": {
    "code": "NOT_A_MEMBER",
    "message": "you are not a member of this channel",
    "client_message_id": "tmp_abc"
  }
}

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.

server → shutdown
{ "type": "shutdown", "data": { "reason": "server shutting down" } }

Close codes

CodeMeaning
1000Normal closure (client or server).
1001Going Away — graceful shutdown.
4401Auth failure. The SDK should not auto-reconnect; refresh the token first.
4400–4499Reserved 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?