Skip to content

WebSocket Gateway Protocol

Together uses a WebSocket gateway for all real-time communication: chat messages, presence updates, and WebRTC voice signaling.


Connection URL

GET ws://your-server:8080/ws?token=<access_token>
GET ws://your-server:8080/ws?bot_token=<static_token>

The access token is passed as a query parameter rather than an Authorization header because browsers cannot set custom headers on WebSocket upgrade requests (a fundamental browser limitation). An invalid or expired token results in a 401 HTTP response before the upgrade completes.

Use a fresh access token on every connection — access tokens expire after 15 minutes.

Bot authentication: Bots can connect using the bot_token query parameter with their static bot token. However, static tokens appear in server and proxy access logs. For production use, prefer exchanging the bot token for a short-lived JWT via POST /bots/connect, then connect with ?token=<jwt> instead.


Message Envelope

All messages in both directions use the same JSON envelope:

json
{
  "op": "OPCODE",
  "t": "EVENT_TYPE",
  "d": {}
}
FieldTypeDescription
opstringOpcode — identifies the message type
tstring | nullEvent type — present only on DISPATCH messages
dobject | nullPayload — shape depends on op and t

Opcodes

OpcodeDirectionDescription
DISPATCHServer → ClientDelivers a named event (t field is set)
HEARTBEATClient → ServerKeep-alive ping to prevent connection timeout
HEARTBEAT_ACKServer → ClientPong response to a HEARTBEAT
PRESENCE_UPDATEClient → ServerUpdate the user's online status (server broadcasts via DISPATCH with t: "PRESENCE_UPDATE")
TYPING_STARTClient → ServerNotify the server that the user started typing (server broadcasts via DISPATCH with t: "TYPING_START")
VOICE_SIGNALClient → ServerWebRTC signaling payload — SDP or ICE candidate (server relays via DISPATCH with t: "VOICE_SIGNAL")

Operational Limits

ParameterValueBehavior on violation
Idle timeout300 sServer closes connection after 5 min idle
Max frame size16 KBOversized frames close the connection
Per-connection rate limit20 msg/sExcess messages are silently dropped

Connection Lifecycle

1. Client opens WebSocket to /ws?token=<access_token>
   (or /ws?bot_token=<static_token> for bots)
2. Server validates the token
   - Invalid/expired token → HTTP 401 before upgrade (connection refused)
3. Server sends READY event with user profile, server list, DM channels, and unread state
4. Client sends HEARTBEAT every 30 seconds to keep the connection alive
5. Server sends HEARTBEAT_ACK in response to each HEARTBEAT
6. Events flow bidirectionally for the session lifetime
7. On disconnect (network drop, token expiry, idle timeout, etc.), client reconnects
   with a fresh access token from /auth/refresh (or /auth/login if refresh token expired)

READY Event

Sent immediately after a successful connection. Contains the authenticated user's profile, the list of servers they belong to, open DM channels, and per-channel unread/mention counts.

The server list uses the raw server shape (not the REST ServerDto) — it does not include member_count. To get a member count, call GET /servers/:id after connection.

json
{
  "op": "DISPATCH",
  "t": "READY",
  "d": {
    "user": {
      "id": "uuid",
      "username": "alice",
      "email": "[email protected]",
      "avatar_url": null,
      "bio": null,
      "pronouns": null,
      "status": "online",
      "custom_status": null,
      "activity": null,
      "created_at": "2025-01-01T00:00:00Z",
      "is_admin": false
    },
    "servers": [
      {
        "id": "uuid",
        "name": "My Gaming Server",
        "owner_id": "uuid",
        "icon_url": null,
        "is_public": false,
        "created_at": "2025-01-01T00:00:00Z",
        "updated_at": "2025-01-01T00:00:00Z"
      }
    ],
    "dm_channels": [
      {
        "id": "uuid",
        "recipient": {
          "id": "uuid",
          "username": "bob",
          "email": null,
          "avatar_url": null,
          "bio": null,
          "pronouns": null,
          "status": "online",
          "custom_status": null,
          "activity": null,
          "created_at": "2025-01-01T00:00:00Z",
          "is_admin": false
        },
        "created_at": "2025-01-01T00:00:00Z",
        "last_message_at": "2025-01-15T08:30:00Z"
      }
    ],
    "unread_counts": [{ "channel_id": "uuid", "unread_count": 5 }],
    "mention_counts": [{ "channel_id": "uuid", "count": 2 }],
    "server_roles": {
      "server-uuid": [
        {
          "id": "role-uuid",
          "server_id": "server-uuid",
          "name": "Admin",
          "permissions": 8192,
          "color": "#E74C3C",
          "position": 10,
          "created_at": "2025-01-01T00:00:00Z"
        }
      ]
    }
  }
}

Server → Client Events (DISPATCH)

MESSAGE_CREATE

Sent to all clients in a channel when a new message is posted.

json
{
  "op": "DISPATCH",
  "t": "MESSAGE_CREATE",
  "d": {
    "id": "uuid",
    "channel_id": "uuid",
    "author_id": "uuid",
    "content": "Hello, everyone!",
    "reply_to": null,
    "edited_at": null,
    "deleted": false,
    "created_at": "2025-01-01T12:00:00Z"
  }
}

MESSAGE_UPDATE

Sent when a message is edited.

json
{
  "op": "DISPATCH",
  "t": "MESSAGE_UPDATE",
  "d": {
    "id": "uuid",
    "channel_id": "uuid",
    "author_id": "uuid",
    "content": "Edited message content",
    "reply_to": null,
    "edited_at": "2025-01-01T12:05:00Z",
    "deleted": false,
    "created_at": "2025-01-01T12:00:00Z"
  }
}

MESSAGE_DELETE

Sent when a message is deleted.

json
{
  "op": "DISPATCH",
  "t": "MESSAGE_DELETE",
  "d": {
    "id": "uuid",
    "channel_id": "uuid"
  }
}

PRESENCE_UPDATE

Sent to all members of a shared server when a user changes their online status.

json
{
  "op": "DISPATCH",
  "t": "PRESENCE_UPDATE",
  "d": {
    "user_id": "uuid",
    "status": "online",
    "custom_status": null,
    "activity": null
  }
}

Status values: online, away, dnd, offline.

VOICE_STATE_UPDATE

Sent to all members of a server when a user joins, leaves, or updates their voice state. The username field is injected by the server on every broadcast.

json
{
  "op": "DISPATCH",
  "t": "VOICE_STATE_UPDATE",
  "d": {
    "user_id": "uuid",
    "channel_id": "uuid",
    "self_mute": false,
    "self_deaf": false,
    "self_video": false,
    "self_screen": false,
    "server_mute": false,
    "server_deaf": false,
    "joined_at": "2025-01-01T12:00:00Z",
    "username": "alice"
  }
}

When a user leaves a voice channel, channel_id and joined_at are null.

VOICE_SIGNAL

Delivers a WebRTC signaling message (SDP offer/answer or ICE candidate) from another user. The signal fields are at the top level of d alongside from_user_id.

json
{
  "op": "DISPATCH",
  "t": "VOICE_SIGNAL",
  "d": {
    "from_user_id": "uuid",
    "type": "offer",
    "sdp": "v=0\r\no=- ...",
    "candidate": null,
    "stream_type": null
  }
}

For ICE candidates, type is "candidate", sdp is null, and candidate contains the ICE candidate string. The stream_type field is forwarded as-is from the sender (e.g. "screen", "camera", or null).

Additional Server → Client Events

The following events are dispatched via the same DISPATCH envelope. Payload shapes vary by event — refer to the handler source code for full field details.

EventDescription
DM_CHANNEL_CREATEA new DM channel was opened with the connected user
DM_MESSAGE_CREATEA new message was sent in one of the user's DM channels
REACTION_ADDA reaction was added to a message in a visible channel
REACTION_REMOVEA reaction was removed from a message in a visible channel
THREAD_MESSAGE_CREATEA new message was posted in a thread the user can see
POLL_VOTEA vote was cast on a poll in a visible channel
TYPING_STARTA user started typing in a channel (server broadcast)
TYPING_STOP(defined but not yet dispatched by the server)
MESSAGE_PINA message was pinned in a channel
MESSAGE_UNPINA message was unpinned from a channel
MEMBER_KICKA member was kicked from the server
MEMBER_BANA member was banned from the server
MEMBER_UNBANA member was unbanned from the server
MEMBER_TIMEOUTA member was timed out (cannot send messages until expiry)
MEMBER_TIMEOUT_REMOVEA member's timeout was removed early
CUSTOM_EMOJI_CREATEA custom emoji was added to a server
CUSTOM_EMOJI_DELETEA custom emoji was removed from a server
GO_LIVE_STARTA user started a live stream in a voice channel
GO_LIVE_STOPA user stopped their live stream in a voice channel
ROLE_CREATEA new role was created in the server
ROLE_UPDATEA role's name, permissions, color, or position was changed
ROLE_DELETEA role was deleted from the server
MEMBER_ROLE_ADDA role was assigned to a server member
MEMBER_ROLE_REMOVEA role was removed from a server member
INVITE_CREATEA new invite link was created for a server
INVITE_DELETEAn invite link was revoked from a server
CHANNEL_OVERRIDE_UPDATEA channel permission override was created or updated
CHANNEL_OVERRIDE_DELETEA channel permission override was removed

The server-broadcast TYPING_START event payload includes user_id, username, channel_id, and timestamp. Clients should auto-expire the typing indicator after ~10 seconds if no further TYPING_START events are received for that user.


Client → Server Messages

HEARTBEAT

Send every ~30 seconds to keep the connection alive.

json
{
  "op": "HEARTBEAT"
}

PRESENCE_UPDATE

Update your own online status. status must be one of: online, away, dnd, offline. Messages with unknown status values are silently dropped.

json
{
  "op": "PRESENCE_UPDATE",
  "d": {
    "status": "away",
    "custom_status": "Playing a game",
    "activity": "Elden Ring"
  }
}

All fields in d are optional except status. custom_status and activity are free-text strings (or null to clear).

TYPING_START

Notify the server that you started typing in a channel. The server validates channel membership before broadcasting to other members.

json
{
  "op": "TYPING_START",
  "d": {
    "channel_id": "uuid"
  }
}

VOICE_SIGNAL

Send a WebRTC signaling message to another participant in your current voice channel. type must be one of "offer", "answer", or "candidate". The signal fields are at the top level of d — there is no nested signal wrapper object.

json
{
  "op": "VOICE_SIGNAL",
  "d": {
    "to_user_id": "uuid",
    "type": "offer",
    "sdp": "v=0\r\no=- ...",
    "candidate": null,
    "stream_type": null
  }
}

The stream_type field is forwarded to the receiving peer as-is (e.g. "screen", "camera", or null).


Voice Signaling Flow

Voice calls use WebRTC peer-to-peer connections with the server acting as a signaling relay.

  Alice                    Server (relay)                  Bob
    |                           |                           |
    |-- POST /channels/:id/voice (join) ------------------>|
    |                           |<-- POST /channels/:id/voice (join) --
    |                           |                           |
    |  VOICE_SIGNAL { type:"offer", sdp:"..." }             |
    |-------------------------->|                           |
    |                           |---VOICE_SIGNAL offer---->|
    |                           |                           |
    |                           |<--VOICE_SIGNAL answer----|
    |<-VOICE_SIGNAL answer------|                           |
    |                           |                           |
    |  <-- ICE candidates exchanged via VOICE_SIGNAL -->    |
    |<------------------------->|<------------------------->|
    |                           |                           |
    |<======= UDP audio stream (SRTP, direct or via TURN) =|

Step-by-step:

  1. Both clients join the voice channel via POST /channels/:id/voice
  2. The initiating client creates an RTCPeerConnection and generates an SDP offer
  3. The offer is sent to the target peer via VOICE_SIGNAL (type: "offer") through the WebSocket
  4. The receiving peer creates an answer and sends it back via VOICE_SIGNAL (type: "answer")
  5. Both sides exchange ICE candidates via VOICE_SIGNAL (type: "candidate")
  6. WebRTC establishes a direct peer-to-peer UDP connection (or via TURN if NAT prevents direct)
  7. Audio flows over the SRTP-encrypted UDP connection

Reconnection

Access tokens expire after 15 minutes. When your WebSocket connection drops (network interruption, token expiry, or server restart):

  1. Call POST /auth/refresh with your refresh token to obtain a fresh access token. If the refresh token has also expired, re-authenticate via POST /auth/login.
  2. Reconnect to /ws?token=<new_token>
  3. The server will send a READY event again — use it to re-sync state

Use exponential backoff for reconnection attempts (start at 1 s, cap at 30 s) to avoid thundering-herd problems after a server restart.


Connection Close Codes

CodeMeaning
1000Normal closure (intentional disconnect)
1001Server going away (restart/shutdown)

Authentication failures result in a 401 HTTP response before the WebSocket upgrade completes, not a WebSocket close frame. Malformed JSON and unknown opcodes are silently dropped — the connection remains open. However, oversized frames (>16 KB) cause the server to close the connection immediately.