Appearance
Eventos WebSocket (/v1/ws)
English version: WebSocket events.
El piso en vivo es push, no sondeo: un WebSocket por dashboard o integración recibe, para su propio tenant y en tiempo real, presencia de agentes, ciclo de vida de llamadas, ticks de pacing, eventos de promesas, ofertas de cola entrante, salud de troncales BYOC, bloqueos de cumplimiento, actividad de SMS y ciclo de vida de conversaciones de voz IA. Es la superficie sobre la que corre el dashboard del propietario.
Conectarse
Conectarse lleva dos llamadas: emite un ticket de vida corta con tu clave de API en la cabecera y luego abre el socket con ese ticket.
POST /v1/ws/ticket Authorization: Bearer dd_... -> { "ticket": "ddtt_...", "expires_in": 30 }
GET /v1/ws?ticket=ddtt_...La segunda llamada hace el upgrade a WebSocket (Bandit/WebSock). Los navegadores no pueden fijar cabeceras en un handshake, así que una credencial sigue viajando en la query string, pero un ticket es de un solo uso y caduca en segundos, de modo que una copia de esa URL en un log no vale nada. Tu clave de larga vida nunca sale de la cabecera de la primera llamada. La justificación completa, en Autenticación.
| Estado | Cuándo |
|---|---|
101 | Upgrade aceptado. |
401 | Ticket desconocido, ya canjeado o caducado: una única respuesta idéntica para los tres. |
403 | Tenant suspendido. |
426 | La petición no es un upgrade a WebSocket. |
429 | too_many_connections: se alcanzó el límite de conexiones del piso en vivo, acotado por token de widget y por tenant. Reintentable cuando se libera una conexión. Un navegador nunca ve este estado: recibe un cierre 1006. |
La llamada del ticket comprueba primero los mismos dos topes. Cuando uno está lleno, POST /v1/ws/ticket no emite nada y responde 429 too_many_connections con error.scope (widget_token o tenant) que, a diferencia del 429 del handshake, un navegador sí puede leer. Detalles y qué hacer con cada scope en Rate limits & caps (en inglés).
No hay nada a lo que suscribirse por nombre: el tema se deriva de la clave autenticada, así que un socket solo recibe los eventos de su propio tenant. Sin handshake de unirse a un canal, sin mensaje de suscripción: los fotogramas empiezan a fluir según ocurren los eventos.
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`
};Un ticket vale para un handshake, así que emite uno nuevo por conexión, también al reconectar.
Reconecta ante 401, espera ante 429. Esos dos necesitan un tratamiento distinto y un cliente que trate igual todo lo que no sea 101 se equivoca con el segundo: reintentar un 429 too_many_connections de inmediato solo quema un ticket recién emitido por intento contra un techo que no se ha movido. Usa backoff con jitter ahí, como en cualquier otro 429.
Lee el scope del 429 del ticket. widget_token significa que este agente ya tiene sus sockets, normalmente en otra pestaña: dilo y deja de reintentar hasta que esa sesión se cierre. tenant significa que el piso en vivo de la cuenta está lleno: espera, y avisa a un administrador si persiste. Un navegador que recibe un 1006 justo después de una emisión correcta puede haber chocado con el mismo tope entre las dos llamadas: emitir de nuevo con backoff te dice cuál fue.
Formato del fotograma
Un objeto JSON por fotograma de texto:
json
{ "event": "call.answered", "data": { "call_id": "b7c8d9e0-..." }, "ts": "2026-06-11T14:29:42Z" }Catálogo de eventos
Los payloads exactos tal como los emite core en main. Cada uno está copiado del código que lo emite o de una prueba de core que lo afirma: se cita fichero y línea por evento.
agent.presence
json
{ "agent_id": "f0e1d2c3-...", "sip_extension": "1000", "presence": "available" }presence ∈ available | ringing | on_call | offline. Fuente: checkin/checkout/transiciones de AgentFSM.
call.originated
json
{
"call_id": "b7c8d9e0-...",
"campaign_id": "c1a2b3c4-...",
"debt_id": "d0e1f2a3-...",
"agent_id": "f0e1d2c3-...",
"agent_ext": "1000",
"to_number": "***1001",
"debt_ref": "***1001"
}Lo emite el runner después de que exista la fila durable del CDR.
Enmascarado de PII en el flujo del piso
to_number Y debt_ref se enmascaran a los últimos 4 caracteres ("***0184", "***1001"): el flujo del piso nunca lleva un E.164 completo. Los paneles y monitores pueden correr en pantallas sin filtrar números de consumidores; obtén el registro completo por REST (autenticado, con registro) cuando de verdad lo necesites.
call.answered
json
{ "call_id": "b7c8d9e0-..." }Fuente: CallFSM (evento ESL de respuesta).
call.bridged
json
{ "call_id": "b7c8d9e0-..." }Fuente: CallFSM (CHANNEL_BRIDGE).
call.amd_verdict
json
{ "call_id": "b7c8d9e0-...", "verdict": "machine" }Lo emite CallFSM cuando la detección de contestador decide el resultado. verdict es uno de 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-..."
}Fuente: el pipeline de CDR al colgar. campaign_id y debt_id son null si la búsqueda de la fila original falló. agent_id se atribuye al controlador actual, no al originador; es null cuando el control está fuera de la población de agentes o core no puede resolver el controlador actual, y en ese caso ningún socket con ámbito de agente recibe el fotograma.
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"
}Se emite una vez por llamada, en el puente, para una marcación que salió sin asiento (una marcación predictiva reserva a su agente al contestar): le dice a ese agente que la llamada es suya. Una marcación con asiento primero no lo emite: su call.originated ya nombró al agente. to_number y debt_ref se enmascaran a los últimos 4 como en call.originated. Fuente: Dialer.Campaigns.Runner (lib/dialer/campaigns/runner.ex:4108-4125, payload afirmado en 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"
}Los cuatro verbos de control a mitad de llamada publican un fotograma cada uno. agent_id es el agente que controla actualmente la llamada (tras una transferencia o toma de control es el destino, no el originador); es null cuando el control salió de la población de agentes, y entonces ningún socket con ámbito de agente recibe el fotograma. to_number está enmascarado a los últimos 4. Los verbos de transferencia no publican fotograma; hangup tampoco aquí: call.ended lo cubre. Fuente: Dialer.Calls (lib/dialer/calls.ex:2185-2204, mapeo de verbos de floor_event/1 en lib/dialer/calls.ex:2201-2207; atribución afirmada en 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>"
}Se emite cuando un callback programado cae porque su número fue retirado. Es la única transición de callback con tap porque es la única que el operador no causó: cause viaja con ella para que el piso diga por qué cayó, no solo que cayó. Fuente: tap de telemetría (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 }
}Lo emite el tick del runner, cada ~2 s mientras la campaña corre. Misma forma que GET /v1/campaigns/{id}/stats.
promise.recorded
json
{
"promise_id": "9a8b7c6d-...",
"debt_id": "d0e1f2a3-...",
"amount_cents": 25000,
"currency": "USD",
"promised_date": "2026-06-21"
}Se emite en las escrituras de promesas, incluidas las promesas creadas mediante dispositions tipadas.
supervision.started
json
{
"action": "listen",
"call_id": "019eb7d6-...",
"supervisor_ext": "2000",
"session_id": "<supervisor leg uuid>",
"audit_id": "<supervision_actions row>",
"to_number": "***0184"
}Se emite cuando un supervisor comienza con éxito listen / whisper / barge / takeover sobre una llamada en vivo, DESPUÉS de escribirse la fila de auditoría durable y de que la central actúe. action es el modo; to_number está enmascarado a los últimos 4 (el E.164 completo nunca cruza este flujo).
supervision.ended
json
{
"action": "listen",
"call_id": "019eb7d6-...",
"supervisor_ext": "2000",
"session_id": "<superseded leg uuid>",
"audit_id": "<supervision_actions row>"
}Se emite cuando una sesión de supervisión es reemplazada: el supervisor cambió de modo (un modo activo por supervisor por llamada, así que la pata anterior cuelga) o la pata terminó. Empareja started/ended por 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"
}La ventana emergente de la entrante: la oferta de una llamada en cola a un agente, emitida antes de que suene el softphone de ese agente, para que un cliente pueda mostrar la ficha antes del timbre. En el carril de agente (ddw_) se entrega solo al socket del agente nombrado en target_agent_id; el carril de tenant recibe todas las del tenant. Los tres campos de contexto de cuenta (debt_id, consumer_ref, debt_ref) son null cuando el número de quien llama no se resuelve a una cuenta; from_number es el caller-ID enmascarado y está presente siempre que la llamada entró con uno, y debt_ref / from_number se enmascaran a los últimos 4. Fuente: tap de telemetría (lib/dialer/events/tap.ex:143-157; payload afirmado en 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"
}Cierra la ventana que abrió queue.offered. Empareja los dos por admission_id. reason es el motivo terminal de la oferta como cadena, o "withdrawn" cuando el propietario deshizo una oferta que sonaba sin fila de cola: la ventana igualmente tiene que cerrarse. Fuente: tap de telemetría (lib/dialer/events/tap.ex:159-187; payload afirmado en 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"
}La transición de salud de una troncal BYOC (ADR #196). state ∈ up | down | unknown; prev_state es el anterior (null en la primera observación). Solo las transiciones viajan por el flujo: el confirm de 5 minutos es durable pero nunca se emite. El fotograma no lleva agent_id, así que el carril ddw_ lo descarta; el carril de tenant lo recibe, emitido una vez por tenant donde la troncal es la efectiva. Fuente: Dialer.Carriers.TrunkHealth.Runner (lib/dialer/carriers/trunk_health/runner.ex:215-232; payload afirmado en 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"
}Un intento de marcación bloqueado por una puerta de cumplimiento: se emite después de que exista la fila durable de gate_blocks, y enmascarado a los últimos 4, de modo que el ticker del piso nunca lleva un E.164 completo. gate nombra la puerta que se activó, rule_id la regla detrás de ella. Fuente: Dialer.Campaigns.Runner (lib/dialer/campaigns/runner.ex:5186-5195; payload afirmado en 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"
}El ciclo de vida de la conversación de voz IA: started en una conversación nueva, updated en cada lote anexado (avanzan fase / sentimiento / traspaso), ended cuando el resultado queda establecido. direction es inbound u outbound; es null cuando el intento de llamada aún no está registrado, y en ese caso route_id también es null; route_id se fija solo para una llamada entrante que llegó por una ruta ai_agent. account_ref se enmascara a los últimos 4. Los campos que aún no aplican son null. Fuente: Dialer.VoiceAI (lib/dialer/voice_ai.ex:721, :777, :818; el fotograma es feed_frame/1 en lib/dialer/voice_ai.ex:1503-1521; enmascarado y ciclo de vida afirmados en 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" }Un SMS entrante clasificado por palabra clave: optout para STOP, optin para START, received para cualquier otra cosa. from_masked está enmascarado a los últimos 4 y el cuerpo del mensaje nunca viaja por el flujo: la fila del libro es el registro completo. (HELP no publica fotograma.) Fuente: Dialer.Sms.Inbound (lib/dialer/sms/inbound.ex:317, :352, :358; fotograma en 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"
}Un SMS saliente aceptado por el proveedor. to_masked está enmascarado a los últimos 4. Fuente: 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"
}Un SMS saliente bloqueado por una puerta de cumplimiento antes de llegar al proveedor. gate nombra la puerta que se activó, rule_id la regla detrás de ella; to_masked está enmascarado a los últimos 4. Fuente: Dialer.Sms.Sender (lib/dialer/sms/sender.ex:318-327).
Latido y reautenticación en vivo
- El servidor hace ping cada 30 s; un pong ausente cierra el socket con el código
1011. - Cada latido vuelve a autenticar el token: una clave revocada (o un tenant suspendido) a mitad del flujo se descarta en un latido con el código de cierre
1008. Trata1008como «vuelve al login / rota las credenciales», no como un error transitorio.
Recetas de cliente
- Reconecta con backoff ante cualquier cierre; al reconectar, vuelve a traer tu línea base por REST (
/v1/agents,/v1/stats/summary,/v1/campaigns/{id}/stats) y luego aplica fotogramas: el flujo lleva deltas, no historial. No hay repetición. - Enruta por
event, ignora nombres de evento desconocidos: pueden añadirse eventos nuevos dentro de v1. - Volver a pedir al recibir la señal es un patrón válido: p. ej. ante
promise.recorded, vuelve a pedir/v1/stats/promisesen lugar de mutar el estado local (es lo que hace el dashboard).
Lo que no va por este flujo
Los fotogramas de intentos bloqueados sí van por el socket: compliance.blocked (una marcación rechazada por una puerta) y sms.blocked (un SMS rechazado antes del proveedor) están documentados arriba. (Los eventos de control de supervisión están aquí, supervision.started/ended arriba, pero el audio en sí viaja por la pata SIP hasta el dispositivo del supervisor, nunca por el socket.) Los webhooks salientes (push de servidor a servidor) son una superficie aparte, ya publicada, con sus propios nombres de evento: ver Webhooks salientes (en inglés).