Skip to content

WebSocket Event Reference

Source of truth:

  • d3chat/backend/app/websocket/router.py
  • d3chat/backend/app/websocket/manager.py
  • d3chat/backend/app/routers/messages.py
  • d3chat/backend/app/routers/channels.py
  • d3chat/backend/app/routers/keys.py
  • d3chat/backend/app/federation/handlers.py
  • d3chat/frontend/src/api/ws.ts

Protocol Scope

The WebSocket channel carries low-latency notifications for:

  • channel message mutation events
  • typing signals
  • device/key health signals
  • channel membership notifications

It is not a complete source of truth. Durable state remains in HTTP APIs backed by database persistence.

Connection and Authentication Lifecycle

Handshake sequence

  1. client obtains one-time ticket from POST /api/v1/auth/ws-ticket
  2. client connects to GET /ws?ticket=<ticket>
  3. server consumes ticket via Redis getdel
  4. on success, server accepts socket and registers connection by user id

Ticket semantics

  • one-time use
  • short TTL (30 seconds)
  • invalid or reused tickets are rejected with close code 4001

Initial subscription behavior

After connect, server queries channel memberships and subscribes this user to all channel:<id> topics.

Server also subscribes each connected user to user:<user_id> personal topic for direct notifications.

Reconnection Semantics

Frontend implementation uses exponential backoff reconnect:

  • starts at 1s
  • doubles up to max 30s
  • reconnect attempt reacquires fresh websocket ticket

Client should, after reconnect:

  1. reload channels
  2. reload active channel messages
  3. reconcile local optimistic state

Message Envelope Contract

Incoming websocket data is JSON with minimum type field:

{
"type": "message.new",
"...": "payload-by-type"
}

Clients should ignore unknown types and log for telemetry/compatibility.

Client -> Server Events

typing.start

{
"type": "typing.start",
"channel_id": "<uuid>"
}

typing.stop

{
"type": "typing.stop",
"channel_id": "<uuid>"
}

presence.update

{
"type": "presence.update",
"status": "online"
}

Allowed status: online, away, offline.

subscribe

{
"type": "subscribe",
"channel_id": "<uuid>"
}

Used when client joins/creates channel during active session and wants immediate realtime feed without full reconnect.

Server -> Client Events

message.new

{
"type": "message.new",
"message": {
"id": "<uuid>",
"channel_id": "<uuid>",
"sender_id": "<uuid|null>",
"sender_device_id": "<uuid|null>",
"content": "<ciphertext-or-text>",
"content_type": "text",
"protocol_version": 1,
"edited_at": null,
"created_at": "<iso8601>",
"reply_to_id": "<uuid|null>",
"reply_to": {
"id": "<uuid>",
"sender_id": "<uuid|null>",
"content": "<original-content>",
"content_type": "text",
"protocol_version": 1
},
"attachments": [
{
"id": "<uuid>",
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size_bytes": 204800,
"url": "/api/v1/attachments/<uuid>/download",
"thumbnail_url": "/api/v1/attachments/<uuid>/thumbnail",
"width": 1920,
"height": 1080
}
]
}
}

The reply_to field is null when the message is not a reply. The attachments array is empty when no files are attached.

For encrypted channels, reply_to.content contains the original ciphertext. Clients should attempt to decrypt using cached plaintext or fall back to displaying "[Encrypted message]".

Source paths:

  • local send flow
  • federated relay flow

message.edit

{
"type": "message.edit",
"message": {
"id": "<uuid>",
"channel_id": "<uuid>",
"content": "<updated-payload>",
"edited_at": "<iso8601>"
}
}

message.delete

{
"type": "message.delete",
"message_id": "<uuid>",
"channel_id": "<uuid>"
}

Typing fanout events

{
"type": "typing.start",
"user_id": "<uuid>",
"channel_id": "<uuid>"
}

and corresponding typing.stop.

channel.new

{
"type": "channel.new",
"channel": {
"id": "<uuid>",
"name": "engineering",
"is_dm": false,
"is_federated": true,
"encryption_type": "sender_keys",
"created_by": "<uuid|null>",
"created_at": "<iso8601>"
}
}

Triggered for member add and DM creation flows.

keys.low_otp

{
"type": "keys.low_otp",
"device_id": "<uuid>",
"remaining": 7
}

Signal for client to replenish one-time prekeys.

sender_key.new

{
"type": "sender_key.new",
"channel_id": "<uuid>",
"device_id": "<uuid>",
"sender_username": "alice",
"origin_server": "chat.example.com"
}

Usually followed by refetch of /keys/channels/{channel_id}/sender-keys.

Delivery and Ordering Characteristics

Ordering

Within one websocket connection, arrival order follows pub/sub delivery order, not strict global causality.

Duplication

Clients should deduplicate by stable ids (e.g., message.id) because reconnect/replay windows can reintroduce already-known events.

Gap handling

If client detects possible event gaps (app resumed, reconnect, background throttling), it should rehydrate from HTTP API pages.

Client State Machine Guidance

  1. ingest event
  2. dedupe
  3. decrypt (ordered where ratchet-sensitive)
  4. merge into sorted message list
  5. trigger UI update

Error handling

  • malformed incoming JSON: ignore and log
  • unknown type: ignore and log
  • decrypt failure: render placeholder + schedule key/state recovery

Mobile Implementation Notes

  1. Keep websocket lifecycle separate from UI screens to avoid accidental disconnect thrash.
  2. Maintain backoff state across transient network changes.
  3. On app foreground resume, verify socket health and resubscribe if needed.
  4. Use local queueing for outbound typing signals if socket is reconnecting.