WebSocket events (/v1/ws)
The live floor is push, not poll: one WebSocket per dashboard/integration receives agent presence, call lifecycle, pacing ticks, and promise events for its tenant in real time. This is the surface the owner dashboard runs on.
Connecting
Connecting takes two calls: mint a short-lived ticket with your API key in the header, then open the socket with that ticket.
POST /v1/ws/ticket Authorization: Bearer dd_... -> { "ticket": "ddtt_...", "expires_in": 30 }
GET /v1/ws?ticket=ddtt_...The second call upgrades to a WebSocket (Bandit/WebSock). Browsers cannot set headers on a handshake, so a credential still rides the query string — but a ticket is single-use and expires in seconds, so a copy of that URL in a log is worth nothing. Your long-lived key never leaves the header of the first call. Full rationale in Authentication.
| Status | When |
|---|---|
101 | Upgrade accepted. |
401 | Ticket unknown, already redeemed, or expired — one identical response for all three. |
403 | Suspended tenant. |
426 | The request is not a WebSocket upgrade. |
429 | too_many_connections — the live-floor connection limit is reached, bounded per widget token and per tenant. Retryable once a connection frees up. |
There is nothing to subscribe to by name: the topic is derived from the authenticated key, so a socket only ever receives its own tenant's events. No channel-join handshake, no subscription message — frames start flowing as events happen.
// Mint server-side: the API key must never reach the browser.
const { ticket } = await fetch("https://api.example.com/v1/ws/ticket", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json());
const ws = new WebSocket(`wss://api.example.com/v1/ws?ticket=${ticket}`);
ws.onmessage = (msg) => {
const { event, data, ts } = JSON.parse(msg.data);
// route on `event`
};A ticket is good for one handshake, so mint a fresh one per connection — including on reconnect.
Reconnect on 401, back off on 429. Those two need different handling and a client that treats every non-101 the same gets the second one wrong: retrying a 429 too_many_connections immediately just burns a freshly minted ticket per attempt against a ceiling that has not moved. Use backoff with jitter there, as on any other 429.
Frame format
One JSON object per text frame:
{ "event": "call.answered", "data": { "call_id": "b7c8d9e0-..." }, "ts": "2026-06-11T14:29:42Z" }Event catalog
Exact payloads as emitted by core v0.3:
agent.presence
{ "agent_id": "f0e1d2c3-...", "sip_extension": "1000", "presence": "available" }presence ∈ available | ringing | on_call | offline. Source: AgentFSM checkin/checkout/transitions.
call.originated
{
"call_id": "b7c8d9e0-...",
"campaign_id": "c1a2b3c4-...",
"debt_id": "d0e1f2a3-...",
"agent_id": "f0e1d2c3-...",
"agent_ext": "1000",
"to_number": "***1001",
"debt_ref": "***1001"
}Emitted by the runner after the durable CDR row exists.
PII masking on the floor stream
to_number AND debt_ref are masked to the last 4 characters ("***0184", "***1001") — the floor stream never carries a full E.164. Wallboards and monitors can run on screens without leaking consumer numbers; pull the full record over REST (authenticated, logged) when you actually need it.
call.answered
{ "call_id": "b7c8d9e0-..." }Source: CallFSM (ESL answer event).
call.bridged
{ "call_id": "b7c8d9e0-..." }Source: CallFSM (CHANNEL_BRIDGE).
call.ended
{
"call_id": "b7c8d9e0-...",
"disposition": "answered_human",
"answered": true,
"campaign_id": "c1a2b3c4-...",
"debt_id": "d0e1f2a3-...",
"agent_id": "f0e1d2c3-..."
}Source: the CDR pipeline at hangup. The ids are null if the original row lookup missed.
campaign.stats
{
"campaign_id": "c1a2b3c4-...",
"dialed": 412,
"blocked": 37,
"finished": 395,
"reaped": 0,
"in_flight": 17,
"pacing": { "ratio": 2.4, "abandoned": 9, "connected": 301, "abandonment_rate": 0.029, "tokens": 6 }
}Emitted by the runner tick, every ~2 s while the campaign is running. Same shape as GET /v1/campaigns/{id}/stats.
promise.recorded
{
"promise_id": "9a8b7c6d-...",
"debt_id": "d0e1f2a3-...",
"amount_cents": 25000,
"currency": "USD",
"promised_date": "2026-06-21"
}Emitted on promise writes — including promises created through typed dispositions.
supervision.started
{
"action": "listen",
"call_id": "019eb7d6-...",
"supervisor_ext": "2000",
"session_id": "<supervisor leg uuid>",
"audit_id": "<supervision_actions row>",
"to_number": "***0184"
}Emitted when a supervisor successfully begins listen / whisper / barge / takeover on a live call — AFTER the durable audit row is written and the switch acts. action is the mode; to_number is masked last-4 (the full E.164 never crosses this stream).
supervision.ended
{
"action": "listen",
"call_id": "019eb7d6-...",
"supervisor_ext": "2000",
"session_id": "<superseded leg uuid>",
"audit_id": "<supervision_actions row>"
}Emitted when a supervision session is superseded — the supervisor switched mode (one active mode per supervisor per call, so the previous leg hangs up) or the leg ended. Pair started/ended by session_id.
Heartbeat and live re-authentication
- The server pings every 30 s; a missing pong closes the socket with code
1011. - Each heartbeat re-authenticates the token: a key revoked (or a tenant suspended) mid-stream is dropped within one heartbeat with close code
1008. Treat1008as "go back to login / rotate credentials", not as a transient error.
Client recipes
- Reconnect with backoff on any close; on reconnect, re-fetch your baseline over REST (
/v1/agents,/v1/stats/summary,/v1/campaigns/{id}/stats) and then apply frames — the stream carries deltas, not history. There is no replay. - Route on
event, ignore unknown event names — new events may be added within v1. - Refetch-on-signal is a valid pattern: e.g. on
promise.recorded, refetch/v1/stats/promisesinstead of mutating local state (this is what the dashboard does).
Not on this stream
Blocked-attempt events and AMD verdicts do not exist on the socket today — see the Roadmap. (Supervision control events are here — supervision.started/ended above — but the audio itself rides the SIP leg to the supervisor's device, never the socket.) Webhook (server-to-server push) delivery is also roadmap; this WebSocket is currently the only push surface.