Skip to content

WebSocket connection

Zentalk delivers all real-time events — messages, presence, receipts, and call signaling — over a single WebSocket connection at GET /ws. Authenticate with a short-lived single-use ticket (recommended) or a JWT passed in the Sec-WebSocket-Protocol header; credentials are never accepted in the URL query string.

Property Value
URL GET /ws (upgrade to WebSocket)
Message format JSON text frames: { "type": string, "payload": object }
Max message size 64 KB
Connections per account 1 (per API instance) — a new connection replaces the existing one

An optional device identifier can accompany the handshake for multi-device routing, either as the device_id query parameter or the X-Device-ID header. A malformed device_id is rejected with HTTP 400 before the upgrade.

The server checks these methods in order and accepts the first that succeeds. Query-string token authentication (?token=) has been removed and is always rejected. See Authentication for obtaining a JWT.

Order Method How to pass Notes
1 Ticket (query) GET /ws?ticket=<ticket-id> Single-use, expires in 30 seconds
2 JWT header Authorization: Bearer <jwt> Not available from browser WebSocket API
3 Ticket (subprotocol) Sec-WebSocket-Protocol: ticket.<ticket-id> Preferred for browsers
4 JWT subprotocol Sec-WebSocket-Protocol: token.<jwt> Browser fallback when a ticket cannot be obtained

If no method succeeds the server responds with HTTP 401 and the upgrade is refused. When a subprotocol is used, the server echoes the exact requested subprotocol back in the upgrade response, as required by the WebSocket specification — browsers close the connection otherwise.

Request a ticket with your JWT immediately before connecting:

POST /api/auth/ws-ticket
Authorization: Bearer <jwt>
Response
{
"ticket": "",
"expires_in": 30
}

Tickets are preferred over the JWT subprotocol because a consumed or expired ticket cannot be used if it leaks into logs or traces.

connect.js
async function connectZentalk(apiBase, wsBase, jwt) {
// 1. Get a single-use ticket (preferred).
let protocols;
try {
const res = await fetch(`${apiBase}/api/auth/ws-ticket`, {
method: "POST",
headers: { Authorization: `Bearer ${jwt}` },
});
const { ticket } = await res.json();
protocols = [`ticket.${ticket}`];
} catch {
// 2. Fall back to JWT in the subprotocol header — never in the URL.
protocols = [`token.${jwt}`];
}
const ws = new WebSocket(`${wsBase}/ws`, protocols);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data); // { type, payload }
if (msg.type === "pong") return;
// dispatch by msg.type — see the Event catalog
};
// Application-level heartbeat every 30 s.
ws.onopen = () => {
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
}
}, 30_000);
};
return ws;
}

After the socket opens, clients may additionally send a redundant { "type": "authenticate", "ticket": "…" } (or "token") frame as a backup; the server ignores it when subprotocol authentication already succeeded.

Once connected, keep the connection alive with heartbeats, reconnect with backoff, and handle deliberate replacement closes.

Two keepalive mechanisms run in parallel:

Mechanism Interval Behavior
Protocol-level ping Every 30 s, server → client Server sends WebSocket ping frames; the client’s pong resets the server’s 60 s read deadline. Browsers answer automatically.
Application-level ping Every 30 s, client → server Client sends { "type": "ping", "timestamp": … }; server replies { "type": "pong" }.

The reference web client treats a pong as overdue after 10 seconds and reconnects after 2 consecutive missed pongs. A connection that sends nothing for 60 seconds is closed by the server.

Reconnect with full-jitter exponential backoff: delay = random(0, min(1,000 ms × 2^attempt, 30,000 ms)). The reference client caps this at 5 attempts before requiring user action. On reconnect, request a fresh ticket — tickets are single-use, so a previous ticket can never be replayed.

Any messages queued for you while offline are delivered automatically after the connection is established.

Each account holds at most one active connection per API instance. Opening a second connection closes the first with close code 1008 (policy violation) and the reason new connection established. Do not treat this close code as a trigger for reconnection loops — it typically indicates that another tab or device has opened a connection. Other closes (network failures, server restarts, read timeouts) surface as abnormal closures; handle them with the reconnection strategy above.

Rate limiting applies per connection and per user (aggregated across all of a user’s connections, so parallel connections cannot multiply the budget). WebSocket ping/pong control frames are exempt.

Scope Burst Sustained rate Configured by
General messages (per connection and per user) 10 messages 30 messages/minute WS_RATE_LIMIT_BURST, WS_RATE_LIMIT_PER_MIN (server env)
WebRTC ICE candidates 30 messages 100 messages/minute fixed
WebRTC signaling (offer, answer, reject, end) 5 messages 10 messages/minute fixed

Messages that exceed a limit are silently dropped; the connection stays open. Design clients so that high-frequency actions (typing indicators, receipts) are debounced. For REST API limits, see the canonical Rate limits reference.

When the server refuses to process a client frame it may respond with an in-band error envelope instead of closing the connection:

{
"type": "error",
"code": "MESH_ONLY_FORBIDDEN",
"message": "Plaintext typing is disabled in mesh-only mode. Use encrypted_typing via mesh."
}

MESH_ONLY_FORBIDDEN is returned for plaintext typing and read_receipt frames on deployments running in mesh-only mode, where only the encrypted metadata events are accepted. See Errors for the general error code registry.

  • Event catalog — every event type and payload delivered over this connection.
  • Calls and WebRTC — call signaling over the WebSocket and ICE server configuration.