Appearance
WebSocket events (/v1/ws)
Español: Eventos WebSocket.
The live floor is push, not poll: one WebSocket per dashboard/integration receives — for its own tenant, in real time — agent presence, call lifecycle, pacing ticks, promise events, inbound-queue offers, BYOC trunk health, compliance blocks, SMS activity, and AI-voice conversation lifecycle. 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. A browser never sees this status: it gets a close 1006. |
The ticket call checks the same two caps first. When one is full, POST /v1/ws/ticket mints nothing and answers 429 too_many_connections with error.scope — widget_token or tenant — which, unlike the handshake's 429, a browser can read. Details and what to do with each scope in Rate limits & caps.
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.
js
// 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.
Read the scope of the ticket's 429. widget_token means this agent already holds its sockets, usually in another tab: say so and stop retrying until that session closes. tenant means the account's live floor is full: back off, and tell an administrator if it persists. A browser that gets a 1006 straight after a successful mint may have hit the same cap between the two calls — minting again with backoff tells you which.
Frame format
One JSON object per text frame:
json
{ "event": "call.answered", "data": { "call_id": "b7c8d9e0-..." }, "ts": "2026-06-11T14:29:42Z" }Event catalog
Exact payloads as emitted by core on main. Every one is copied from the code that emits it or from a core test that asserts it — file and line cited per event.
agent.presence
json
{ "agent_id": "f0e1d2c3-...", "sip_extension": "1000", "presence": "available" }presence ∈ available | ringing | on_call | offline. Source: AgentFSM checkin/checkout/transitions.
call.originated
json
{
"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
json
{ "call_id": "b7c8d9e0-..." }Source: CallFSM (ESL answer event).
call.bridged
json
{ "call_id": "b7c8d9e0-..." }Source: CallFSM (CHANNEL_BRIDGE).
call.amd_verdict
json
{ "call_id": "b7c8d9e0-...", "verdict": "machine" }Emitted by CallFSM when answering-machine detection decides the result. verdict is one of unspecified | human | machine | not_sure.
call.ended
json
{
"call_id": "b7c8d9e0-...",
"disposition": "answered_human",
"answered": true,
"campaign_id": "c1a2b3c4-...",
"debt_id": "d0e1f2a3-...",
"agent_id": "f0e1d2c3-..."
}Source: the CDR pipeline at hangup. campaign_id and debt_id are null if the original row lookup missed. agent_id is attributed to the current controller, not the originator; it is null when control is outside the agent population or core cannot resolve the current controller, and no agent-scoped socket receives the frame in that case.
call.assigned
json
{
"call_id": "b7c8d9e0-...",
"campaign_id": "c1a2b3c4-...",
"debt_id": "d0e1f2a3-...",
"to_number": "***0101",
"debt_ref": "***ef-1",
"agent_id": "f0e1d2c3-...",
"agent_ext": "1000"
}Emitted once per call, at the bridge, for a dial that left with no seat (a predictive dial reserves its agent at answer): it tells that agent the call is theirs. A seat-first dial does not emit it — its call.originated already named the agent. to_number and debt_ref are masked last-4 like call.originated. Source: Dialer.Campaigns.Runner (lib/dialer/campaigns/runner.ex:4108-4125, payload asserted in test/dialer/campaigns/runner_test.exs:6172).
call.held / call.unheld / call.muted / call.unmuted
json
{
"call_id": "b7c8d9e0-...",
"agent_id": "f0e1d2c3-...",
"audit_id": "<call control audit row>",
"to_number": "***0184"
}The four mid-call control verbs publish one frame each. agent_id is the agent who currently controls the call (after a transfer/takeover that is the destination, not the originator); it is null when control left the agent population, and then no agent-scoped socket receives the frame. to_number is masked last-4. The transfer verbs publish no frame; hangup publishes none here either — call.ended covers it. Source: Dialer.Calls (lib/dialer/calls.ex:2185-2204, floor_event/1 verb mapping at lib/dialer/calls.ex:2201-2207; attribution asserted in test/db/calls_test.exs:971-983).
callback.quarantined
json
{
"callback_id": "9a8b7c6d-...",
"debt_id": "d0e1f2a3-...",
"campaign_id": "c1a2b3c4-...",
"scheduled_at": "2026-06-21T15:00:00Z",
"cause": "<why the callback fell>"
}Emitted when a scheduled callback falls because its number was retired. It is the only callback transition with a tap because it is the only one the operator did not cause — cause rides along so the floor says why it fell, not just that it did. Source: telemetry tap (lib/dialer/events/tap.ex:126-133).
campaign.stats
json
{
"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
json
{
"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
json
{
"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
json
{
"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.
queue.offered
json
{
"call_id": "019eb7d6-...",
"queue_id": "4c1d2e3f-...",
"admission_id": "7a8b9c0d-...",
"target_agent_id": "f0e1d2c3-...",
"offered_at": "2026-06-11T14:29:42Z",
"debt_id": "d0e1f2a3-...",
"consumer_ref": "CMS-4711",
"debt_ref": "***0001",
"from_number": "***0142"
}The inbound screen-pop: the offer of a queued call to one agent, emitted before that agent's softphone rings, so a client can pop the card ahead of the ring. On the agent lane (ddw_) it is delivered only to the socket of the agent named by target_agent_id; the tenant lane gets the whole tenant's. The three account-context fields (debt_id, consumer_ref, debt_ref) are null when the caller's number does not resolve to an account; from_number is the masked caller-ID and is present whenever the call came in with one, and debt_ref / from_number are masked last-4. Source: telemetry tap (lib/dialer/events/tap.ex:143-157; payload asserted in test/db/queue_screen_pop_test.exs:79-92).
queue.offer_expired
json
{
"call_id": "019eb7d6-...",
"queue_id": "4c1d2e3f-...",
"admission_id": "7a8b9c0d-...",
"target_agent_id": "f0e1d2c3-...",
"reason": "no_answer"
}Closes the pop that queue.offered opened. Pair the two by admission_id. reason is the offer's terminal reason as a string, or "withdrawn" when the owner undid an offer that was ringing without a queue row — the pop still has to close. Source: telemetry tap (lib/dialer/events/tap.ex:159-187; payload asserted in test/db/queue_screen_pop_test.exs:168-180).
trunk.health
json
{
"carrier_id": "2f3a4b5c-...",
"state": "up",
"prev_state": null,
"reason": "probe_active",
"observed_at": "2026-10-06T12:00:00Z"
}A BYOC trunk's health transition (ADR #196). state ∈ up | down | unknown; prev_state is the previous one (null on the first observation). Only transitions ride the stream — the 5-minute confirm is durable but never emitted. The frame carries no agent_id, so the ddw_ lane drops it; the tenant lane gets it, emitted once per tenant where the trunk is the effective one. Source: Dialer.Carriers.TrunkHealth.Runner (lib/dialer/carriers/trunk_health/runner.ex:215-232; payload asserted in test/dialer/carriers/trunk_health_runner_test.exs:343-352).
compliance.blocked
json
{
"block_id": "<gate_blocks row>",
"campaign_id": "c1a2b3c4-...",
"debt_id": "d0e1f2a3-...",
"gate": "dnc_listed",
"rule_id": "dnc.lists.v1",
"contact_phone_masked": "***0101",
"occurred_at": "2026-06-11T14:29:42Z"
}A dial attempt blocked by a compliance gate — emitted after the durable gate_blocks row exists, and masked last-4, so the floor ticker never carries a full E.164. gate names the gate that fired, rule_id the rule behind it. Source: Dialer.Campaigns.Runner (lib/dialer/campaigns/runner.ex:5186-5195; payload asserted in test/dialer/campaigns/runner_test.exs:3838-3847).
ai.conversation.started / ai.conversation.updated / ai.conversation.ended
json
{
"id": "8b9c0d1e-...",
"call_id": "b7c8d9e0-...",
"ai_agent_id": "...",
"direction": "inbound",
"route_id": "3d4e5f60-...",
"account_ref": "***8899",
"us_state": "TX",
"phase": "wrapup",
"sentiment": "calm",
"handoff_suggested": false,
"duration_secs": 42,
"started_at": "2026-06-11T14:28:00Z",
"ended_at": "2026-06-11T14:28:42Z",
"outcome_type": "resolved"
}The AI-voice conversation lifecycle: started on a new conversation, updated on each appended batch (phase / sentiment / handoff advance), ended when the outcome is settled. direction is inbound or outbound — null when the call attempt is not recorded yet, in which case route_id is null too; route_id is set only for an inbound call that arrived on an ai_agent route. account_ref is masked last-4. Fields that do not apply yet are null. Source: Dialer.VoiceAI (lib/dialer/voice_ai.ex:721, :777, :818; the frame is feed_frame/1 at lib/dialer/voice_ai.ex:1503-1521; masking and lifecycle asserted in test/db/voice_ai_test.exs:943-958).
sms.received / sms.optin / sms.optout
json
{ "from_masked": "***0142", "to": "+13125550100", "occurred_at": "2026-06-11T14:29:42Z" }An inbound SMS classified by keyword: optout for STOP, optin for START, received for anything else. from_masked is masked last-4 and the message body never rides the stream — the ledger row is the full record. (HELP publishes no frame.) Source: Dialer.Sms.Inbound (lib/dialer/sms/inbound.ex:317, :352, :358; frame at lib/dialer/sms/inbound.ex:661-670).
sms.sent
json
{
"message_id": "6e7f8a9b-...",
"provider_message_id": "<provider id>",
"to_masked": "***0142",
"segments": 1,
"occurred_at": "2026-06-11T14:29:42Z"
}An outbound SMS accepted by the provider. to_masked is masked last-4. Source: Dialer.Sms.Sender (lib/dialer/sms/sender.ex:478-484).
sms.blocked
json
{
"decision_id": "<compliance decision>",
"gate": "dnc_listed",
"rule_id": "dnc.lists.v1",
"to_masked": "***0142",
"occurred_at": "2026-06-11T14:29:42Z"
}An outbound SMS blocked by a compliance gate before it reaches the provider. gate names the gate that fired, rule_id the rule behind it; to_masked is masked last-4. Source: Dialer.Sms.Sender (lib/dialer/sms/sender.ex:318-327).
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 frames are on the socket: compliance.blocked (a dial refused by a gate) and sms.blocked (an SMS refused before the provider) are documented above. (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.) Outbound webhooks (server-to-server push) are a separate, shipped surface with their own event names — see Outbound webhooks.