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.
Endpoint
Section titled “Endpoint”| 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.
Authentication
Section titled “Authentication”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.
Getting a ticket
Section titled “Getting a ticket”Request a ticket with your JWT immediately before connecting:
POST /api/auth/ws-ticketAuthorization: Bearer <jwt>{ "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.
Browser example
Section titled “Browser example”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.
Connection lifecycle
Section titled “Connection lifecycle”Once connected, keep the connection alive with heartbeats, reconnect with backoff, and handle deliberate replacement closes.
Heartbeat
Section titled “Heartbeat”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.
Reconnection
Section titled “Reconnection”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.
Connection replacement
Section titled “Connection replacement”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 limits
Section titled “Rate limits”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.
Error frames
Section titled “Error frames”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.
Next steps
Section titled “Next steps”- Event catalog — every event type and payload delivered over this connection.
- Calls and WebRTC — call signaling over the WebSocket and ICE server configuration.
