Skip to Content
ChatChat & Presence — Socket.IO Event Catalog

Chat & Presence — Socket.IO Event Catalog

Conventions

  • C→S — emitted by the client, handled by the server
  • S→C — emitted by the server, received by the client
  • All payloads are JSON objects
  • Timestamps are ISO 8601 strings
  • The server never trusts client-provided senderId; it always uses socket.data.userId

Connection Lifecycle

Handshake (C→S, implicit)

The client provides its auth token at connection time. No explicit event — this is handled by the Socket.IO middleware before any events fire.

// Dashboard / Manage (JWT cookie — sent automatically via withCredentials) const socket = io(API_URL + '/tenant', { withCredentials: true }); // DM portal (JWT in auth option) const socket = io(API_URL + '/tenant', { auth: { token: '<jwt>' }, }); // Manage super-admin connects to /manage namespace const socket = io(API_URL + '/manage', { withCredentials: true });

On successful handshake:

  • Server joins socket to tenant:{tenantId} (auto, from token claims)
  • Server joins socket to user:{userId} (auto, personal room)
  • Server sets presence:{userId} Redis key
  • Server emits user:online to tenant:{tenantId}

On failed handshake (invalid/expired token):

  • Server calls next(new Error('UNAUTHORIZED'))
  • Client receives connect_error with message: 'UNAUTHORIZED'
  • Client should redirect to login

Presence Events

presence:list (C→S)

Request the current list of online users visible to this client.

// Payload {} // Server response — emitted back only to this socket socket.emit('presence:list', { users: [ { userId: 'clx...', name: 'Alice', image: 'https://...', role: 'admin', tenantId: 'ten...', onlineSince: '2026-04-03T10:00:00Z', }, // ... ], });

For /tenant namespace: returns online users in the same tenant. For /manage namespace: returns all online users across all tenants (includes tenantId in each entry so the admin can group by tenant).

presence:heartbeat (C→S)

Sent by the client every 60 seconds to keep the presence key alive. No response needed.

// Payload {}

user:online (S→C)

Broadcast to the tenant room when a user connects.

// Payload { userId: 'clx...', name: 'Alice', image: 'https://...', role: 'admin', tenantId: 'ten...', onlineSince: '2026-04-03T10:00:00Z', }

user:offline (S→C)

Broadcast to the tenant room when a user disconnects (clean or TTL expiry via a background sweeper).

// Payload { userId: 'clx...', tenantId: 'ten...', }

Chat Events

chat:send (C→S)

Send a direct message to another user.

// Payload { recipientId: 'clx...', // target user ID content: string, // plain text, max 4000 chars clientMsgId: string, // client-generated UUID for dedup / optimistic UI }

Server response — emitted back to sender only (ack):

socket.emit('chat:sent', { clientMsgId: string, // echoes the clientMsgId for optimistic UI reconciliation messageId: string, // server-assigned DB ID createdAt: string, // ISO timestamp persisted in DB });

Server also emits chat:message to the recipient’s personal room user:{recipientId}:

// Emitted to recipient { messageId: string, tenantId: string, senderId: string, senderName: string, senderImage: string | null, content: string, createdAt: string, }

chat:message (S→C)

Received by a user when someone sends them a DM. See payload above.

chat:history (C→S)

Load conversation history with a specific user.

// Payload { peerId: string, // the other user in the conversation before?: string, // ISO timestamp — load messages before this (pagination cursor) limit?: number, // default 30, max 100 }

Server response — emitted back to requesting socket only:

socket.emit('chat:history', { peerId: string, messages: [ { messageId: string, senderId: string, content: string, createdAt: string, readAt: string | null, }, // ... oldest first ], hasMore: boolean, // true if there are older messages });

chat:read (C→S)

Mark all messages from a specific sender as read.

// Payload { peerId: string, // the sender whose messages are being marked read }

Server:

  1. Updates readAt for all unread ChatMessage records where recipientId = socket.data.userId AND senderId = peerId AND readAt IS NULL
  2. Emits chat:read_ack back to the reading user (for multi-tab sync)
  3. Emits chat:read_receipt to the original sender
// chat:read_ack — emitted to user:{readerId} (all tabs of the reader) { peerId: string, readAt: string, } // chat:read_receipt — emitted to user:{senderId} (all tabs of the sender) { readerId: string, readAt: string, }

chat:typing (C→S)

Optional. Notify the recipient that the sender is typing.

// Payload { recipientId: string, isTyping: boolean, }

Server emits chat:typing to user:{recipientId}:

{ senderId: string, isTyping: boolean, }

No persistence — ephemeral signal only.

chat:error (S→C)

Emitted to the sender when a chat:send fails.

{ clientMsgId: string, // echoes the original clientMsgId code: 'FORBIDDEN' | 'CONTENT_TOO_LONG' | 'RATE_LIMITED' | 'INTERNAL_ERROR', message: string, }

Error Handling Summary

ScenarioServer actionClient receives
Expired tokennext(new Error('UNAUTHORIZED'))connect_error
Sending to user outside tenantemit chat:error FORBIDDENchat:error
Content > 4000 charsemit chat:error CONTENT_TOO_LONGchat:error
DB write failsemit chat:error INTERNAL_ERRORchat:error

The client should never rely on the server emitting a response to consider an action done — always use the explicit ack events (chat:sent, chat:history, etc.).

© 2026 Leadmetrics — Internal use only