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¶
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:
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.
Unsubscribe when leaving a context (e.g. leaving a server):
On reconnect, re-send subscribe with the full current list — subscriptions are not persisted across connections.
Heartbeat¶
Send every 30 seconds:
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¶
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:
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¶
Remove the message from the local cache.
TYPING_START¶
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¶
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¶
Remove the member from the local member list. For rolling_key channels: trigger a key rotation to exclude the departed member.
ROLE_UPDATE¶
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¶
A new DM channel was opened with this user (someone DMed you for the first time). Refresh the DM channel list.
PRESENCE_UPDATE¶
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.