Realtime
Using the WebSocket
One socket per browser tab or bot process. Agents, bots and visitors use the same endpoint and envelope; the ticket decides who you are.
Connect
- Get a ticket: agents and bots
POST /v1/ws_ticket(bot token); visitorsPOST /v1/customer/ws_ticket(customer token). The response holdsurl. - Open
url:wss://<same host>/v1/ws?v=1&ticket=<ticket>. - Send
loginwithin 10 s (see Client → server actions). Nothing else is accepted before it.
Ticket. 32 random bytes, base64url. It binds the principal kind, organization, agent or customer ID, and for visitors the verified host_origin.
Upgrade checks (failures are plain HTTP, no socket):
| Check | Agent session | Bot | Customer |
|---|---|---|---|
| Ticket valid and unused | 401 | 401 | 401 |
| Extra | session still valid | token not revoked | not banned (403); host_origin in the ticket still in allowed_domains (403); org under its concurrent-visitor cap (429) |
The host page's origin is checked against the organization's allowed_domains when the token and the ticket are issued (host_origin), and the iframe itself is served with Content-Security-Policy: frame-ancestors <allowed_domains>, which the browser enforces and a page can't spoof.
Envelope
JSON text frames. Max inbound frame 16 KiB.
// client → server request
{ "request_id": "c-42", "action": "send_event", "payload": { … } }
// server → client response (exactly one per request_id)
{ "request_id": "c-42", "action": "send_event", "type": "response", "success": true, "payload": { … } }
{ "request_id": "c-42", "action": "send_event", "type": "response", "success": false, "payload": { "error": { "code": "conflict", "message": "chat is closed" } } }
// server → client push
{ "action": "incoming_event", "type": "push", "payload": { … } }While the account is read-only, send_event from agents and visitors fails with subscription_locked, the visitor login answers online: false and the widget shows its offline form; typing, receipts, page updates and set_routing_status still work.
request_id is any client string up to 64 chars. Error codes are the API’s (validation, forbidden, not_found, conflict, rate_limited, …). An unknown action gets success:false with validation; an unparseable frame closes with 4002.
Resume and gap detection
- The client keeps
last_seqper chat it has open (the highest contiguous seq applied). - On every (re)connect it sends
login {resume: {chat_id: last_seq, …}}. loginresponseresume:{<chat_id>: {replayed_to: seq, truncated: bool}}.truncated(or a chat the client didn't list) means fetch the chat again withget_chatorlist_threads(both APIs have them).- State pushes (threads, tags, receipts, routing, traffic) are not replayed. After any reconnect call
list_chatsagain (visitors:get_chat). - Gap rule for
incoming_event:seq ≤ last_seq→ duplicate, drop.seq = last_seq + 1→ apply.seq > last_seq + 1→ hold it, fetch the missed events with the API (list_threads), apply in order. A state push whoseseqis ahead oflast_seqis also a gap signal. - Seq is per chat across its threads, so a resumed chat (new thread) continues the same sequence.
Idempotency
client_message_id (UUID, v7 preferred) is required on every send_event, on the socket or with the API. A retry with the same ID returns the original event and duplicate: true, whatever the retried body says. Retry the same ID after any timeout or reconnect; never mint a new one for the same user action.
Heartbeats and presence
- Server sends a WebSocket ping every 25 s; no pong within 10 s → the server drops the connection (the client sees an abnormal close, 1006, and reconnects).
- Client sends
pingevery 25 s (browsers can't see protocol pings). No frame of any kind within 10 s after itsping→ the client closes and reconnects. - An agent with no live socket for 30 s becomes
offline: their chats move to accepting agents in the group, else the queue. Signing out applies this at once. - Visitor presence drives Traffic: first socket →
customer_visit_started; no socket for 60 s →customer_visit_ended.
Close codes and client reaction
| Code | Reason | Client does |
|---|---|---|
| 1000 | normal (sign-out, chat-only socket closed by client) | nothing |
| 1009 | frame over 16 KiB | fix the client; reconnect |
| 1011 | server error | reconnect with backoff |
| 1012 | service restart | reconnect after a random 0 to 5 s, then resume |
| 4000 | no login within 10 s |
reconnect with backoff |
| 4001 | session or token revoked/expired | bots: stop, the token is no longer valid; visitors: POST /v1/customer/token again, then a new ticket |
| 4002 | protocol error (unparseable frame) | fix the client; reconnect with backoff |
| 4003 | forbidden (customer banned, agent removed) | do not reconnect; visitors show the ended state |
| 4008 | rate limit exceeded (see Rate limits) | reconnect after 30 s |
| 4009 | slow consumer: send buffer (64 frames) full | reconnect at once and resume; nothing is lost |
Backoff: full jitter, 0.5 s doubling to 30 s, reset after a socket has stayed up 60 s. Every reconnect needs a new ticket.
Backpressure. Each socket has a bounded send buffer of 64 frames. Pushes about the same chat are never dropped silently.
Rate limits
Per visitor socket (public, untrusted):
| What | Limit | Over the limit |
|---|---|---|
send_event |
20 per 10 s, burst 5 | success:false, rate_limited |
send_sneak_peek |
2 per s | extra frames dropped silently (latest wins) |
send_typing_indicator |
2 per s | dropped silently |
mark_events_as_* |
coalesced to 1 write per s: a mark inside the second is acknowledged with watermark: null and the latest per chat is written when the second ends |
|
update_customer_page |
30 per min | rate_limited |
| all frames | 50 per 10 s | close 4008 |
Visitor connections: 5 sockets per customer, 20 per IP, and a per-organization concurrent cap (default 5000) enforced at POST /v1/customer/ws_ticket (429). Ticket issue: 30/min per customer.
Agents and bots: send_event 30 per 10 s, all frames 100 per 10 s (close 4008), 10 sockets per agent.
Message text is 1 to 4000 characters everywhere.