Skip to content

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.

StatusWhen
101Upgrade accepted.
401Ticket unknown, already redeemed, or expired — one identical response for all three.
403Suspended tenant.
426The request is not a WebSocket upgrade.
429too_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. Treat 1008 as "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/promises instead 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.

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.