WebSocket Event Reference
Source of truth:
d3chat/backend/app/websocket/router.pyd3chat/backend/app/websocket/manager.pyd3chat/backend/app/routers/messages.pyd3chat/backend/app/routers/channels.pyd3chat/backend/app/routers/keys.pyd3chat/backend/app/federation/handlers.pyd3chat/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
- client obtains one-time ticket from
POST /api/v1/auth/ws-ticket - client connects to
GET /ws?ticket=<ticket> - server consumes ticket via Redis
getdel - 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:
- reload channels
- reload active channel messages
- 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
Recommended per-channel pipeline
- ingest event
- dedupe
- decrypt (ordered where ratchet-sensitive)
- merge into sorted message list
- 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
- Keep websocket lifecycle separate from UI screens to avoid accidental disconnect thrash.
- Maintain backoff state across transient network changes.
- On app foreground resume, verify socket health and resubscribe if needed.
- Use local queueing for outbound typing signals if socket is reconnecting.