Realtime events
ZebChat pushes changes over Socket.IO 4 as they happen: new messages, typing, receipts, assignments and visitors coming and going.
Connecting
Connect to the API origin (path /socket.io) on one of two namespaces:
/agentfor agent apps. Authenticate with an access token and the organization to follow:{ token, organizationId }. You need theconversations:readpermission. One socket follows one organization; reconnect to switch./visitoris used by the widget, with a visitor token.
import { io } from 'socket.io-client';
const socket = io('https://api.zebchat.com/agent', {
transports: ['websocket'],
auth: { token: accessToken, organizationId: 'org_…' },
});
socket.on('message.created', (envelope) => {
console.log(envelope.conversationId, envelope.data.body);
});
// Commands are acknowledged: { ok: true, data } or { ok: false, error: { status, message } }
const ack = await socket.emitWithAck('message.send', {
conversationId: 'conv_…',
body: 'Hello!',
clientMessageId: crypto.randomUUID(),
});A failed handshake ends in connect_error with the message Unauthorized (refresh the access token once and reconnect) or Forbidden (no access to that organization any more).
The event envelope
Every event the server sends is wrapped in a versioned envelope. New optional fields and new events keep version: 1; a breaking change bumps the version, and both versions are sent during a transition.
interface RealtimeEnvelope<TData> {
event: string; // 'message.created', … (also the Socket.IO event name)
version: 1;
organizationId: string; // org_…
conversationId?: string; // conv_…, on conversation events
timestamp: string; // ISO 8601, UTC
data: TData;
}Events
| Event | Sent to | When |
|---|---|---|
conversation.created | Agents | A new conversation |
conversation.updated | Agents, visitor | Status, assignment, tags, priority or queue position changed |
conversation.reopened / .closed | Agents, visitor | A conversation was reopened or closed |
conversation.assigned | Agents | Assigned or unassigned, with the reason |
conversation.transferred | Agents | Transferred to an agent or team, with the note |
message.created | Agents, visitor | A new message (visitors never get internal notes) |
message.delivered / .read | Agents, visitor | Delivery and read receipts |
typing.started / .stopped | Agents, visitor | Someone is typing |
visitor.connected | Agents | A visitor opened your site |
visitor.updated | Agents | An online visitor moved to another page |
visitor.disconnected | Agents | A visitor’s last tab closed |
agent.online / .status / .offline | Agents | Agent presence and status |
agent.joined / .left | Agents | A co-agent joined or left a chat, or a supervisor started or stopped monitoring it |
conversation.rated | Agents | The visitor rated an ended chat or changed the rating |
ai.typing / message.delta | Agents, visitor | The AI assistant is writing: draft chunks to append, then the final message.created |
ai.status | Agents | The AI key failed, the spend cap was reached, or AI resumed |
knowledge.source.updated / .deleted | Agents | Knowledge-base source progress and changes |
availability.updated | Visitors of a website | The widget went online or offline (agents online, business hours) or its team faces changed |
data has the same shape as the matching REST resource: a Conversation as returned by GET …/conversations/{id}, a message as in GET …/messages, a live visitor as in GET …/visitors/live.
Commands
Clients send commands with an acknowledgement (emitWithAck). Errors use HTTP status codes: 400 invalid payload, 403 not allowed, 404 not found, 409 conversation closed.
| Command | Payload |
|---|---|
message.send | {conversationId, body, clientMessageId, attachmentIds?, internal?}: replies with the stored message |
typing | {conversationId, typing: true | false}: send at most every few seconds |
message.delivered | {conversationId, messageId} |
message.read | {conversationId, messageId}: also resets your unread count |
agent.status | {status: online | away | busy} (agents only) |
Delivery guarantees
- Every message gets a
seqnumber, strictly increasing within its conversation. Events are sent only after the change is saved. - After every connect and reconnect, fetch what you missed with
GET …/conversations/{id}/messages?after=<last message id>. - De-duplicate by message
id, and match your own optimistic messages by theirclientMessageId. Retrying a send with the sameclientMessageIdnever creates a second message. - Internal notes also take a
seq, so visitors may see gaps in the numbers. That is expected.
Presence (who is online) is not replayed: after connecting, load GET /agents and GET /visitors/live, then apply the agent.* and visitor.* events.
Server-side disconnects
A socket’s permissions are fixed when it connects, so the server closes agent sockets when access changes: on logout, a revoked or expired session, a password change or reset, or a role change or removal. Socket.IO then reports io server disconnect and does not reconnect by itself. Reconnect once with fresh credentials; the handshake decides whether access continues.
Behind a load balancer, use the WebSocket transport (as in the example above) or sticky sessions. See the REST API overview for authentication.