WebSocket Protocol Reference
Endpoint: GET /ws (upgrade to WebSocket)
Transport: JSON messages over WebSocket
Source: packages/server/src/ws/handler.ts, packages/server/src/ws/events.ts
Auth Flow
- Client connects to
/ws
- Client sends
{ type: 'auth', token: '<jwt>' } within 10 seconds
- Server validates token (rejects deleted users, tokens issued before
passwordChangedAt)
- Server responds with
ready event containing full client state
- Server updates user status to
online, broadcasts presence_update to friends + DM co-members + space co-members (via collectProfileBroadcastTargetIds); for native users, also queues a S2S presence_update relay to all active peers
- Heartbeat: server pings every 30s (RFC 6455 ping frames), dead connections detected after ~65s
Client → Server
Messages
| type |
fields |
notes |
message_create |
channelId, content, replyToId? |
SEND_MESSAGES perm |
message_edit |
messageId, content |
author only |
message_delete |
messageId |
author or MANAGE_MESSAGES |
typing_start |
channelId |
5s auto-expire |
DM Messages
| type |
fields |
notes |
dm_message_create |
dmChannelId, content?, attachments?, replyToId? |
member |
dm_message_edit |
messageId, content |
author only |
dm_message_delete |
messageId |
author only |
dm_typing_start |
dmChannelId |
5s auto-expire |
Reactions (space + DM, auto-detected)
| type |
fields |
notes |
reaction_add |
messageId, emoji |
ADD_REACTIONS perm (space) |
reaction_remove |
messageId, emoji |
own reactions only |
Read State
| type |
fields |
notes |
channel_ack |
channelId, messageId |
mark read up to message |
mark_unread |
channelId, messageId |
'0' to clear all |
Presence & Activity
| type |
fields |
notes |
presence_update |
status: online/idle/dnd |
persisted to DB |
activity_update |
activities: Activity[] |
rate-limited 3s, respects showActivity |
Voice (Space Channels)
| type |
fields |
notes |
voice_join |
channelId |
one room per user enforced |
voice_leave |
— |
|
voice_status |
isMuted, isDeafened, isCameraOn, isScreenSharing |
server enforces space/permission mute |
Voice Moderation
| type |
fields |
permission |
voice_space_mute |
userId, muted |
MUTE_MEMBERS |
voice_space_deafen |
userId, deafened |
DEAFEN_MEMBERS |
voice_move |
userId, targetChannelId |
MOVE_MEMBERS |
voice_disconnect |
userId |
DISCONNECT_MEMBERS |
DM Calls
| type |
fields |
notes |
dm_call_start |
dmChannelId?, federatedCallId? |
dmChannelId can be null when federatedCallId is provided. 60s auto-timeout if not accepted |
dm_call_accept |
dmChannelId?, federatedCallId? |
ringing→active |
dm_call_reject |
dmChannelId?, federatedCallId? |
|
dm_call_end |
dmChannelId?, federatedCallId? |
|
System
| type |
fields |
auth |
token |
ping |
— (gets pong) |
Server → Client
System
| type |
fields |
scope |
ready |
(see Ready Payload below) |
user |
pong |
— |
user |
error |
message |
user |
Messages
| type |
fields |
scope |
message_created |
message: MessageWithUser |
channel (VIEW_CHANNEL) |
message_updated |
message: MessageWithUser |
channel |
message_deleted |
messageId, channelId |
channel |
typing |
channelId, userId, username |
channel (excludes sender) |
reaction_added |
messageId, reaction (includes user) |
channel |
reaction_removed |
messageId, userId, emoji |
channel |
embeds_resolved |
messageId, channelId, embeds[] |
channel |
DM Messages
| type |
fields |
scope |
dm_message_created |
message: DmMessageWithUser |
DM members |
dm_message_updated |
message: DmMessageWithUser |
DM members |
dm_message_deleted |
messageId, dmChannelId |
DM members |
dm_typing |
dmChannelId, userId, username |
DM members (excludes sender) |
dm_typing_stop |
dmChannelId, userId |
DM members (excludes typer) |
dm_embeds_resolved |
messageId, dmChannelId, embeds[] |
DM members |
Read State
| type |
fields |
scope |
channel_ack |
channelId, messageId |
user (multi-tab sync) |
mark_unread |
channelId, messageId |
user (multi-tab sync) |
Presence & Activity
| type |
fields |
scope |
presence_update |
userId, status, activities? |
friends + DM co-members + space co-members of the user (via collectProfileBroadcastTargetIds), plus self for multi-tab sync. For federated stubs, the local instance receives status via S2S presence_update relay from the home (see federation.md §10 — Presence Sync) and re-broadcasts to the same recipient set. |
user_updated |
user |
user |
Space / Channel Management
| type |
fields |
scope |
space_updated |
space |
space |
member_joined |
spaceId, member: MemberWithUser |
space |
member_left |
spaceId, userId |
space |
member_banned |
spaceId, reason |
user (banned) |
channel_created |
channel, spaceId |
space |
channel_updated |
channel, spaceId |
space |
channel_deleted |
channelId, spaceId |
space |
category_created |
category, spaceId |
space |
category_updated |
category, spaceId |
space |
category_deleted |
categoryId, spaceId |
space |
channel_layout_updated |
spaceId, channels[], categories[] |
space |
space_layout_updated |
layout[], folders[], updatedAt? |
user |
DM Channel Management
| type |
fields |
scope |
dm_channel_created |
dmChannel |
user |
dm_channel_closed |
dmChannelId |
user |
dm_member_added |
dmChannelId, user |
DM members |
dm_member_removed |
dmChannelId, userId |
DM members |
dm_owner_updated |
dmChannelId, newOwnerId, newOwnerHomeUserId?, newOwnerHomeInstance? |
DM members |
Voice
| type |
fields |
scope |
voice_state_update |
channelId, userId, action: join/leave |
space |
voice_status_update |
userId, channelId, isMuted, isDeafened, isCameraOn, isScreenSharing |
room |
space_voice_state |
spaceId, voiceStates, voiceUserStates, spaceVoiceStates |
the joining user. Scoped per-space voice-presence snapshot pushed when a user joins a space mid-session (see below). |
voice_space_muted |
userId, channelId, spaceId, muted |
space |
voice_space_deafened |
userId, channelId, spaceId, deafened |
space |
voice_permission_muted |
userId, spaceId, muted |
space |
voice_moved |
userId, oldChannelId, newChannelId |
user (target) |
voice_disconnected |
userId, channelId, reason? |
user (target) |
reason: 'displaced' (new tab) |
'session_closed' |
|
DM Calls
| type |
fields |
scope |
dm_call_incoming |
dmChannelId?, federatedCallId, callerId, callerName, callOrigin?, livekitUrl?, livekitToken? |
DM members (excludes caller). dmChannelId can be null for Path B federated calls (no local DM channel). callOrigin identifies the hosting instance for cross-instance calls. |
dm_call_accepted |
dmChannelId?, federatedCallId? |
DM members |
dm_call_rejected |
dmChannelId?, federatedCallId? |
DM members |
dm_call_ended |
dmChannelId?, federatedCallId? |
DM members |
dm_call_undeliverable |
Sent to the originator when a call relay (start / accept / reject / end) to one or more peers fails. Includes phase: 'start' | 'accept' | 'reject' | 'end' | 'host_unreachable' identifying the action; failures[] enumerates failed peers with a reason (peer_rejected / peer_awaiting_approval / peer_transient_failure / livekit_unavailable / no_recipient). terminal: true means local call state should be (or has been) torn down; terminal: false is informational. See docs/systems/voice.md for the full phase × terminal matrix. |
originator (caller / acceptor / rejector / ender) |
Social
| type |
fields |
scope |
friend_request_received |
request |
user (target) |
friend_request_accepted |
friend, requestId |
user (requester) |
friend_request_declined |
requestId, userId |
user (requester) |
friend_request_cancelled |
requestId, userId |
user (target) |
friend_removed |
userId |
user |
Discovery
| type |
fields |
scope |
join_request_received |
request |
space (managers) |
join_request_accepted |
request, space |
user (requester) |
join_request_declined |
request |
user (requester) |
Federation
| type |
fields |
scope |
federation_file_rejected |
messageId, dmChannelId, attachmentId, affectedUsers[] |
DM members |
federation_approval_request_received |
— (refetch trigger; payload: { type }) |
admins. Fires for both inbound peering requests (remote → us) AND outbound queue creation when the Outbound Peering Gate creates a peer_approval_requests row in response to a user_action. Payload shape unchanged from the inbound-only behavior; only the firing surface widened. |
federation_peer_reset_detected |
{ origin: string } |
admins. Fires from markPeerReset when a peer's advertised instance epoch differs from the trusted baseline (a wipe-and-reinstall on the same domain — see Reset Detection). Detection-only: the peer was routed to needs_attention (reason peer_reset_detected) with no rekey/tombstone. Paired with a federation_peers_changed broadcast. Client handler: onFederationPeerResetDetected(cb) (useWebSocket.ts) — the FederationPanel's Reset-cleanup surface subscribes and refetches GET /api/federation/peers + GET /api/federation/reset-events, raising a persistent banner with one-click Re-peer (see admin.md "FederationPanel", client-federation.md §8). |
peering_subscription_changed |
— (refetch trigger; payload: { type }) |
the subscribing user (all of their connected sessions). Fires when a peer_approval_subscribers row belonging to the user is created, modified, or deleted (gate fan-in, user cancel, parent cascade). Client refetches GET /api/federation/peering-subscriptions. |
peering_notification_received |
{ type, kind: 'approved' | 'denied' | 'expired' } |
the user the notification belongs to. Fires when a peer_approval_notifications row is created (onPeerActivated outbound fanout, outbound /deny fanout, janitor outbound expiry). Client refetches GET /api/federation/peering-notifications and may surface a transient toast for online users. |
S2S relay-only event (not a direct client WS event):
read_state_update — sent peer-to-peer via the federation relay when a user acknowledges a DM channel on one instance. The receiving instance processes it, upserts the read_states row, and then emits a standard channel_ack event to the user's local WebSocket connections. The relay event itself is never forwarded to clients directly.
Ready Payload
Federation filtering: When the connecting user is federated (homeInstance is set), the server omits all DM-related data from the ready payload. dmChannels and activeCalls are sent as empty arrays, and readStates is filtered to only include space channel entries. Federated users receive their DM data from their home instance's ready payload instead.
Voice-state assembly: voiceStates / voiceUserStates / spaceVoiceStates for each of the user's spaces are produced by ConnectionManager.buildSpaceVoiceState(spaceId, userId) — the single source of truth shared with the mid-session join push (see below). Voice presence is VIEW_CHANNEL-filtered per computePermissions: a user is never told who occupies a voice channel they cannot see.
Mid-session space join — space_voice_state push
The ready payload is the only carrier of voice presence at connect time. When a user joins a space mid-session (invite, public join, or join-request approval) without reloading, they would otherwise see empty voice channels until a refresh, because member_joined carries no voice state and GET /api/spaces/:id (the channel-sidebar hydrator) has none either.
To close this, ConnectionManager.addUserSpace(userId, spaceId) — the single chokepoint every join path funnels through, and which is not used on reconnect (that path uses setUserSpaces) — builds the same per-space snapshot via buildSpaceVoiceState and pushes it to the joining user as a space_voice_state event. Delivery rides the same ordered WebSocket as the voice_state_update deltas, so there is no snapshot-vs-stream race. The push is skipped when the space has no active voice and no restrictions (e.g. space creation). The client applies it scoped to spaceId (utils/voiceStateSync.applySpaceVoiceState): it merges occupants/statuses and rebuilds only that space's restriction keys, never disturbing voice state in other spaces.