Skip to content

WebSocket Gateway

Partly out of date

Rendered from ProjectInfo/client/gateway.md on docs/encryption-modes-spec. Its protocol content is still useful, but parts of it describe an older client or an earlier API. Where it disagrees with the code, the code wins — see the spec errata.

The full event table and the real heartbeat rules are in the spec errata.

The gateway delivers all real-time events. One persistent connection per client session.

Connecting

GET wss://<WS_BASE>/gateway?token=<access_token>

The access token goes in the query string — WebSocket APIs in browsers and most platforms cannot set custom headers on the initial handshake.

On successful connection the server immediately sends:

{ "op": "hello" }

On every hello (including reconnects): discard all cached messages and re-fetch from REST. Do not attempt selective gap-filling — a full reload is cheaper and always correct.


Message Format

All frames are JSON text.

Client → Server:

{
  op: "subscribe" | "unsubscribe" | "typing" | "heartbeat";
  channel_ids?: string[];   // for subscribe / unsubscribe
  server_ids?: string[];    // for subscribe / unsubscribe
  channel_id?: string;      // for typing
}

Server → Client:

{
  op: "event" | "heartbeat_ack" | "hello" | "error";
  type?: string;     // set when op === "event"
  data?: unknown;    // event payload
  message?: string;  // set when op === "error"
}


Connection Lifecycle

Client                              Server
  │── GET /gateway?token=xxx ──────►│  (JWT validated at connect time)
  │◄── { "op": "hello" } ───────────│
  │
  │── { "op": "subscribe",
  │    "channel_ids": [...],
  │    "server_ids":  [...] } ──────►│
  │
  │◄── stream of events ────────────│
  │
  │── { "op": "heartbeat" } ────────►│  (every 30 seconds)
  │◄── { "op": "heartbeat_ack" } ───│

Clients that do not send a heartbeat within 60 seconds are disconnected.


Subscribing

Subscribe to channels and servers to receive events. Subscribe to all channels the user can read, not just the active one — this ensures unread badge counts update correctly.

{ "op": "subscribe", "channel_ids": ["uuid1", "uuid2"], "server_ids": ["uuid3"] }

Unsubscribe when leaving a context (e.g. leaving a server):

{ "op": "unsubscribe", "channel_ids": ["uuid1"], "server_ids": [] }

On reconnect, re-send subscribe with the full current list — subscriptions are not persisted across connections.


Heartbeat

Send every 30 seconds:

{ "op": "heartbeat" }

Server responds with { "op": "heartbeat_ack" }. If no ack arrives within a few seconds, assume the connection is dead and reconnect.


Reconnect Strategy

Use exponential backoff: 1s → 2s → 4s → 8s → ... cap at 30s.

On reconnect: 1. Connect with the current (possibly refreshed) access token 2. Wait for hello 3. Re-send subscribe with all current IDs 4. Re-fetch REST for any data that may have changed while offline (messages, member list, etc.)


Typing

{ "op": "typing", "channel_id": "uuid" }

Emit when the user starts typing. Server broadcasts TYPING_START to all channel subscribers. Do not send more than once per second.


Events Reference

All events arrive as:

{ "op": "event", "type": "<EVENT_TYPE>", "data": { ... } }

MESSAGE_CREATE

Fired when a message is posted in any subscribed channel.

{
  id: string;
  channel_id: string;
  author_id: string;
  author_name: string;
  author_avatar: string | null;
  content: string;              // may be ciphertext — check encryption_mode
  encryption_mode: "server_encrypted" | "rolling_key" | "peer_provided";
  epoch_id: string | null;      // present for rolling_key messages
  type: number;                 // 0 = DEFAULT
  reply_to_id: string | null;
  pinned: boolean;
  edited: boolean;
  created_at: string;
}

For rolling_key channels: content is ciphertext. Look up the epoch key for epoch_id in your local key store and decrypt before displaying. If the key is not yet available, show a placeholder.

MESSAGE_UPDATE

Same shape as MESSAGE_CREATE with edited: true and edited_at set. Replace the existing message in the local cache by id.

MESSAGE_DELETE

{ "id": "uuid", "channel_id": "uuid" }

Remove the message from the local cache.

TYPING_START

{ "channel_id": "uuid", "user_id": "uuid" }

Show a typing indicator for this user. Expire client-side after 8 seconds if no further TYPING_START arrives for the same user_id + channel_id pair.

MEMBER_JOIN

{ "server_id": "uuid", "user_id": "uuid", "username": "string" }

Add the member to the local server member list. For rolling_key channels in this server: trigger a key rotation (see messaging.md).

MEMBER_LEAVE

{ "server_id": "uuid", "user_id": "uuid" }

Remove the member from the local member list. For rolling_key channels: trigger a key rotation to exclude the departed member.

ROLE_UPDATE

{ "server_id": "uuid", "role_id": "uuid", "permissions": 12345 }

Permissions changed for a role. Re-fetch the full role list for that server and recompute which channels are visible and which permission-gated actions are available.

DM_OPEN

{ "channel_id": "uuid" }

A new DM channel was opened with this user (someone DMed you for the first time). Refresh the DM channel list.

PRESENCE_UPDATE

{ "user_id": "uuid", "status": 1, "status_text": "string | null" }

Update the user's displayed status in the member list and friend list.

Status values: 1 ONLINE · 2 IDLE · 3 DND · 4 OFFLINE · 5 INVISIBLE

USER_UPDATE

{
  "user_id": "uuid",
  "username": "string",
  "display_name": "string | null",
  "avatar_url": "string | null",
  "banner_url": "string | null",
  "bio": "string | null"
}

Delivered on user.<id>.USER_UPDATE — only to the user's own sessions. Update the local profile cache.


Server Buffer

The server buffers up to 256 events per client. If the buffer overflows while the client is disconnected, events are dropped. On reconnect, re-fetch from REST to fill any gaps — do not assume the event stream is lossless.