Appearance
API REST
English version: REST API.
La Referencia de la API generada es la fuente siempre al día
Esta página es una narrativa escrita a mano de la superficie /v1 según core v0.9, que se conserva por su guía y su contexto. Para la referencia completa y garantizadamente vigente de los endpoints —generada en cada build a partir de la especificación OpenAPI de core, vigilada contra desviaciones (v0.12.x, con SMS, DIDs, operadores telefónicos y rutas entrantes)— usa la Referencia de la API.
Referencia de la API del plano de control de core (/v1), tal como está implementada en core v0.9 (la superficie de facturación y cuenta). Todos los endpoints salvo GET /healthz exigen una clave de API: ver Autenticación.
Las convenciones (sobre de error, ids de petición, tenancy, marcas de tiempo) están descritas en el resumen de Developers.
Para ejercitar la superficie en lugar de leerla, importa la colección de Postman: 27 carpetas sobre /v1, tomadas del harness de integración con la credencial local eliminada.
Índice de endpoints
| Método | Ruta | Propósito |
|---|---|---|
GET | /healthz | Sonda de vida (sin autenticación). |
GET | /v1/me | El tenant autenticado (incluye tier, voice_ai_enabled). |
PATCH | /v1/tenant | Autoedición de la cuenta. SOLO name: el plan, los asientos y el complemento son superficie de la plataforma. |
GET | /v1/tenant/calling-window | El horario de llamadas propio de la cuenta (null si no hay). Publicado en core v0.87.0. |
PUT | /v1/tenant/calling-window | Fija el horario semanal de llamadas de la cuenta; solo lo estrecha. Clave dd_ o account_admin. Misma nota. |
DELETE | /v1/tenant/calling-window | Elimina el horario de llamadas de la cuenta. Misma nota. |
GET | /v1/tenants | Lista de un elemento: tu propio tenant. |
GET | /v1/tenants/{id} | Tu tenant, o 404. |
GET | /v1/api_keys | Lista las claves de API. |
POST | /v1/api_keys | Emite una clave (el texto en claro se muestra una sola vez). |
DELETE | /v1/api_keys/{id} | Revoca una clave. |
GET | /v1/users | Usuarios del dashboard y sus invitaciones abiertas. |
POST | /v1/users | Invita a una persona al dashboard (202; sin contraseña). |
DELETE | /v1/users/{id} | Deshabilita a un usuario (nunca lo borra). |
POST | /v1/users/{id}/enable | Vuelve a habilitar a un usuario deshabilitado. |
POST | /v1/auth/password-reset/confirm | Canjea una invitación o un enlace de restablecimiento con la contraseña que elige la persona. |
GET | /v1/campaigns | Lista las campañas. |
POST | /v1/campaigns | Crea una campaña (estado draft). |
GET | /v1/campaigns/{id} | Obtiene una campaña. |
PATCH | /v1/campaigns/{id} | Actualiza la configuración (nunca state). |
POST | /v1/campaigns/{id}/start | running + arranca el runner del bucle de marcado. |
POST | /v1/campaigns/{id}/resume | Alias de start. |
POST | /v1/campaigns/{id}/pause | paused, detiene el runner. |
POST | /v1/campaigns/{id}/stop | completed (terminal). |
POST | /v1/campaigns/{id}/archive | draft|completed → archived. |
GET | /v1/campaigns/{id}/stats | Contadores en vivo del runner y del ritmo de marcado. |
GET/POST | /v1/campaigns/{id}/rewind/preview | Simulacro del rewind. |
POST | /v1/campaigns/{id}/rewind | Reencola las deudas que coinciden. |
GET | /v1/ai/voices | El catálogo gobernado de voces para agentes de IA; ?language= filtra. Publicado en core v0.87.0. |
POST | /v1/ai/agents/{id}/voice-preview | Una URL firmada de corta vida con la voz guardada del agente leyendo una frase fija. Publicado en core v0.88.0. |
POST | /v1/ai/agents/{id}/test | Un turno de la conversación de prueba del agente: sin llamada ni conversación. Con topes; ver los límites de la conversación de prueba. |
GET/POST | /v1/rewind-segments | Segmentos de rewind guardados: lista los vigentes / guarda un conjunto de filtros con nombre. |
PATCH | /v1/rewind-segments/{id} | Renombra un segmento o reemplaza sus filtros. |
POST | /v1/rewind-segments/{id}/retire | Retira un segmento (terminal). |
POST | /v1/debts/import | Importación masiva de leads en JSON (idempotente). |
GET/POST | /v1/import-mappings | Plantillas de importación de CSV: lista (con la versión actual de cada una) / crea (versión 1). |
GET/PATCH/DELETE | /v1/import-mappings/{id} | Una plantilla; PATCH la renombra o le añade una versión nueva; DELETE la archiva. |
GET | /v1/import-mappings/{id}/versions[/{version}] | Todas las versiones de una plantilla, de la más antigua a la más reciente / una versión. |
GET | /v1/import-mappings/{id}/uses | Las importaciones que nombraron la plantilla, con la versión que usó cada una. |
GET | /v1/debts/{id} | Deuda + contactos. |
GET | /v1/debts/{id}/call_attempts | Historial CDR durable completo de una deuda. |
GET | /v1/consumers/{id} | Ficha del deudor: deudas, DNC / cese de comunicaciones, contador de Reg F, próxima ventana permitida. |
GET | /v1/debts/{id}/defense-packet | Paquete sellado de evidencia para litigio (JSON o ZIP). |
POST | /v1/debts/{id}/defense-packet/archives | Archiva el ZIP con retención y un sello de tiempo RFC 3161. Publicado en core v0.88.0. |
GET | /v1/debts/{id}/defense-packet/archives | Lista las atestaciones de archivo de la deuda. Publicado en core v0.88.0. |
GET | /v1/debts/{id}/defense-packet/archives/{aid}/attestation | Obtiene la atestación, el token del sello de tiempo y una URL de corta vida del ZIP. Publicado en core v0.88.0. |
GET | /v1/call_attempts | Consulta del CDR filtrada y paginada por keyset. |
GET | /v1/reports/cdr | La misma consulta del CDR como protojson canónico dialer.v1.CallDetailRecord. |
POST | /v1/call_attempts/{id}/disposition | Resultado tipado del agente o de la IA (+ promesa para PTP). |
GET | /v1/promises | Lista las promesas. |
PATCH | /v1/promises/{id} | pending → kept|broken|cancelled. |
POST | /v1/callbacks | Programa una rellamada acordada con el consumidor. |
GET | /v1/callbacks | Vista de la cola / alimentación de vencidas del runner. |
POST | /v1/callbacks/{id}/complete | pending → done. |
POST | /v1/callbacks/{id}/cancel | pending → cancelled. |
POST | /v1/callbacks/{id}/miss | pending → missed. |
GET | /v1/agents | Plantilla de agentes + presencia en vivo. |
POST | /v1/agents | Crea un asiento de agente. |
GET | /v1/agents/{id} | Un agente + presencia. |
PATCH | /v1/agents/{id} | Un asiento: corregirlo o darlo de baja (status: inactive). |
POST | /v1/agents/checkin | El asiento pasa a available (por sip_extension; device_mode opcional). |
POST | /v1/agents/checkout | El asiento pasa a fuera de línea. |
POST | /v1/agents/{id}/pause | El asiento pasa a paused (cuerpo reason). |
POST | /v1/agents/{id}/resume | paused → available. |
POST | /v1/supervision/calls/{call_id}/listen | Monitoreo silencioso de una llamada en vivo. |
POST | /v1/supervision/calls/{call_id}/whisper | Coachea al agente (solo el agente lo oye). |
POST | /v1/supervision/calls/{call_id}/barge | Tres vías (ambos lados oyen al supervisor). |
POST | /v1/supervision/calls/{call_id}/takeover | Transfiere al deudor al supervisor. |
GET | /v1/dids | Registro de DIDs (filtra por status). |
POST | /v1/dids | Registra un número propiedad del tenant. |
GET | /v1/dids/{id} | Un DID. |
PATCH | /v1/dids/{id} | Metadatos/estado (e164 inmutable). |
DELETE | /v1/dids/{id} | Retira (las filas nunca se borran). |
GET | /v1/dids/{id}/health | Salud de respuesta/llamadas cortas calculada a partir del CDR. |
GET | /v1/stats/summary | Contadores operativos de hoy. |
GET | /v1/stats/promises | Resumen del pipeline de PTP. |
GET | /v1/stats/penetration | Analítica de penetración a nivel de persona. |
GET | /v1/stats/contact-windows | Tasa de contacto por hora local del consumidor, mejor ventana de llamada, teléfonos por persona. |
GET | /v1/stats/blocked | Resumen durable de marcados bloqueados (por compuerta + diario). |
GET | /v1/stats/ai-usage | Minutos de IA de voz medidos (JSON o CSV). |
GET | /v1/reports/violations-prevented | Artefacto de cumplimiento empaquetado (JSON o CSV). |
POST | /v1/privacy/erasure-requests | Simulacro (por defecto) o ejecución del borrado de una persona; la ejecución está apagada por defecto (detalle). |
GET | /v1/billing/summary | Líneas facturables + total (JSON o CSV). |
Además, GET /v1/ws: el piso en vivo por WebSocket.
Salud
GET /healthz
Sonda de vida sin autenticación.
json
{ "status": "ok", "service": "dialer-core", "version": "0.3.0" }Identidad
GET /v1/me
El tenant autenticado. Con una clave de API (dd_…), como abajo, es todo lo que devuelve; con una sesión de inicio de sesión del dashboard (ddu_…, correo y contraseña) lleva además el user: ver Usuarios del dashboard:
json
{
"tenant": {
"id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"name": "local-dev",
"status": "active",
"retention_months": 39,
"seats": 5,
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z"
}
}GET /v1/tenants / GET /v1/tenants/{id}
Endpoints en paridad con el dashboard. Como las claves de API están acotadas al tenant, la lista siempre tiene exactamente un elemento (tu tenant), y {id} devuelve tu tenant o 404 not_found: nunca un 403, nunca una filtración de existencia.
Usuarios del dashboard: invitaciones
Las personas de tu equipo inician sesión en el dashboard con su propio correo y contraseña. Tú no las creas ni fijas jamás su contraseña: las invitas, y cada persona elige su contraseña al aceptar (core v0.77.0, ADR #182 de core).
POST /v1/users — invitar a una persona
json
{ "email": "[email protected]", "name": "Ops Admin" }202, siempre con exactamente este cuerpo:
json
{ "status": "invited", "message": "invitation recorded" }- La misma respuesta tanto si la dirección está libre como si ya tiene una cuenta en otra parte. Una dirección libre recibe un correo con un enlace de un solo uso para aceptar la invitación. Una dirección que ya es usuaria de otra cuenta de Dialer Digital recibe un aviso de que ya tiene una cuenta, sin enlace. Ambas son
202con el cuerpo de arriba, byte por byte, y ambas invitaciones se registran y se listan igual: una invitación no te dice nada de otras cuentas y no les dice nada de la tuya. 409 conflictsolo para tu propia gente: la dirección ya es usuaria de tu cuenta, activa o deshabilitada; lo ves en tu propioGET /v1/users. Una deshabilitada se recupera conPOST /v1/users/{id}/enable, no con una invitación nueva.400 bad_requestsi el cuerpo trae unpassword. La contraseña la elige la persona invitada.- El enlace vale 72 horas y funciona una sola vez. Invitar de nuevo a la misma dirección reemplaza el enlace anterior: así se reenvía una invitación y así se renueva una vencida.
429 rate_limitedcuando tu cuenta ha enviado demasiadas invitaciones en poco tiempo (20 por ventana de un minuto por defecto). Reintenta más tarde.- Cada dirección tiene además su propio tope, entre todas las cuentas (3 invitaciones por ventana). Por encima, la invitación se registra igualmente y la respuesta sigue siendo el mismo
202, pero no sale ningún correo. Si una persona dice que la invitación nunca le llegó, invítala de nuevo un poco más tarde. - Quién puede invitar. Cuando la cuenta tiene un
account_adminactivo, invitar exige un inicio de sesiónaccount_admindel dashboard (ddu_…): un inicio de sesiónmembery una clavedd_reciben403 role_forbiddeny no se registra nada (core#2334, core#2449). Una cuenta sinaccount_adminactivo conserva la postura anterior: la clavedd_es la forma en que se invita a la primera persona. Un administrador deshabilitado no cuenta. - Quién la hizo. La invitación guarda su autor, que no cambia: mira
invited_byenGET /v1/users.
Aceptar: la persona elige la contraseña
El enlace abre la página /accept-invite del dashboard, que lo canjea con POST /v1/auth/password-reset/confirm ({"token": "…", "new_password": "…"}): la misma ruta que un restablecimiento de contraseña. Ese es el momento en que la persona se crea en tu cuenta, con la contraseña que eligió (de 12 a 128 caracteres). Un enlace desconocido, vencido, ya usado o reemplazado por uno más nuevo —o cuya dirección se haya convertido entretanto en usuaria de otra parte— es un único 401 idéntico. Una contraseña demasiado corta es 422, y el enlace sigue funcionando.
GET /v1/users
Devuelve users e invitations. Las invitations son las abiertas, la más reciente primero: pending hasta que vencen, expired después. Una invitación a una dirección que ya tiene una cuenta en otra parte se lista, y vence, exactamente igual que una que nadie abrió. Una invitación aceptada sale de la lista y su persona aparece en users. Cada invitación trae invited_by, quién la hizo: kind es user (un inicio de sesión del dashboard, y user_id es esa persona), api_key (una clave dd_, user_id es null) u operator. kind es null en una invitación registrada antes de que existiera el campo. Queda fijado al crear la invitación, así que tras revocar una clave la invitación que hizo sigue leyéndose api_key.
DELETE /v1/users/{id} deshabilita a un usuario —las filas nunca se borran— y termina todas las sesiones que tenga. POST /v1/users/{id}/enable lo vuelve a habilitar; las sesiones terminadas por la deshabilitación siguen terminadas, así que la persona inicia sesión de nuevo. Ambas exigen el mismo rol que invitar: con un account_admin activo, una clave dd_ o un inicio de sesión member reciben 403 role_forbidden.
Una integración que da de alta personas con una clave dd_
Si tu integración invita, deshabilita o rehabilita personas con una clave dd_, deja de funcionar en cuanto la cuenta tiene un account_admin activo: POST /v1/users, DELETE /v1/users/{id}, POST /v1/users/{id}/enable, POST /v1/api_keys y DELETE /v1/api_keys/{id} responden 403 role_forbidden. Una clave filtrada ya no puede invitar un buzón del atacante ni emitir más claves. Salidas: hacer esas llamadas desde un inicio de sesión account_admin del dashboard, o pedirle a Dialer Digital que encienda api_key_manages_people en tu cuenta, un ajuste de la plataforma (apagado por defecto; no puedes fijarlo tú) que devuelve a la clave su alcance anterior. Existen además los ajustes por cuenta user_management_admin_only y api_key_admin_only, también del lado de la plataforma.
Migrar desde el antiguo POST /v1/users (cambio incompatible en core v0.77.0)
Antes de core v0.77.0, POST /v1/users aceptaba un password, creaba el usuario al instante y respondía 201 con él; una dirección que era usuaria de cualquier cuenta era un 409. Si tu integración crea usuarios del dashboard:
- Deja de enviar
password. Un cuerpo que lo traiga es400y no se registra nada. - Espera
202con el cuerpo fijo, sin usuario y sin id: la persona no existe hasta que acepta. Sigue la invitación enGET /v1/users(invitations), y a la persona enuserscuando acepte. - Lee
409como «ya está en tu cuenta», no como «ocupada en otra parte»: una dirección ocupada por otra cuenta es ahora un202como cualquier otra.
Campañas
Ciclo de vida de una campaña: draft → running ⇄ paused → completed (terminal) → archived. Una draft también puede archivarse directamente. El ciclo de vida es explícito: state no es un campo aceptado de PATCH /v1/campaigns/{id}, así que enviarlo hace fallar la petición entera con 400 bad_request nombrando la clave; usa los endpoints del ciclo de vida.
GET /v1/campaigns
json
{
"campaigns": [
{
"id": "c1a2b3c4-d5e6-7f80-91a2-b3c4d5e6f708",
"tenant_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"name": "local-demo",
"state": "draft",
"abandonment_threshold": "0.03",
"min_dial_ratio": "1.0",
"max_dial_ratio": "3.0",
"calling_window_start": "08:00:00",
"calling_window_end": "21:00:00",
"default_timezone": "America/Chicago",
"record_calls": false,
"ai_voice": false,
"carrier": null,
"caller_ids": ["+13125550199"],
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z"
}
]
}Los campos decimales (abandonment_threshold, las razones de marcado) se serializan como cadenas para evitar la deriva del punto flotante.
POST /v1/campaigns
Todos los campos son opcionales salvo name:
json
{
"name": "june-chargeoffs",
"abandonment_threshold": 0.03,
"min_dial_ratio": 1.0,
"max_dial_ratio": 2.8,
"calling_window_start": "08:00:00",
"calling_window_end": "21:00:00",
"default_timezone": "America/Chicago",
"record_calls": false,
"ai_voice": false,
"carrier": "your-byoc-trunk",
"caller_ids": ["+13125550199"]
}201 {"campaign": {...}}: las campañas nuevas empiezan en draft. Un name duplicado → 409 conflict.
Inicio y fin de calendario
Publicado en core
v0.87.0(core#1501, ADR #206 de core), y en la Referencia de la API.
POST /v1/campaigns y PATCH /v1/campaigns/{id} aceptan dos campos anulables:
json
{
"default_timezone": "America/Chicago",
"starts_at": "2026-11-02T09:00:00",
"ends_at": "2026-11-30T17:00:00"
}- Son valores de hora de reloj en
default_timezone. Usa exactamenteYYYY-MM-DDTHH:MM:SS, sinZni desfase UTC. Si vienen los dos,ends_atdebe ser posterior astarts_at. Envíanullpara borrar cualquiera de los dos campos. - La plataforma solo mueve los estados que el campo nombra. Con la capacidad habilitada, el planificador mueve una campaña de voz que sigue en
draftarunningcuando se alcanzastarts_at, siempre que su plan permita un arranque y suends_atno haya pasado también. Mueve una campaña de voz que está enrunningacompleted, terminal, cuando se alcanzaends_at. No reinicia una campaña que una persona pausó o que la plataforma marcó comoexhausted; en una campaña de SMS los campos quedan guardados e inertes. - La bandera operativa está apagada por defecto. Guardar estos campos no programa nada hasta que el operador de la plataforma habilita
campaign_window_enabledpara la cuenta. Mientras esté apagada, una campaña solo arranca conPOST /v1/campaigns/{id}/starty la plataforma no la completa enends_at. - Un arranque manual no perdona el final. Si una persona arranca una campaña después de su
ends_atguardado mientras la capacidad está habilitada, la siguiente pasada del planificador la completa otra vez. Borra o mueve antesends_atsi no es lo que quieres.
Esta ventana de calendario controla el ciclo de vida de la campaña. No sustituye a la ventana diaria de llamadas, al horario opcional por día, al horario de llamadas de la cuenta ni a las horas de silencio legales; esos siguen decidiendo si una llamada concreta puede salir mientras la campaña está en marcha.
Campañas de voz artificial: la divulgación suena primero y se exige una tecla de exclusión
Los cuatro tipos de voz sintetizada —tts_blast, tts_ivr, ivr_cascade y survey— siguen dos reglas (47 CFR §64.1200(b)):
- Tu divulgación abre cada llamada. El mensaje que se sintetiza es la divulgación de voz artificial almacenada de tu cuenta (
PUT /v1/artificial-voice-disclosure) seguida deltts_promptde la campaña, como un solo audio. En los tipos con menú, cada repetición del menú repite también la divulgación. Sin una divulgación almacenada válida la campaña no marca en absoluto, yGET /v1/campaigns/{id}/statsinformaprompt_unavailable_reason: "disclosure_missing". Cambiar la divulgación vuelve a sintetizar el mensaje de todas tus campañas TTS; cada una espera a que su audio nuevo esté listo. Guardarla exige un administrador:PUT /v1/artificial-voice-disclosureacepta una clave de tenant (dd_) o un inicio de sesiónaccount_admin; un inicio de sesiónmemberrecibe403 role_forbidden(leerla conGETsigue abierto). - Sin
optout_digit, no hay llamada. Una campaña de estos tipos necesita unoptout_digit: una sola tecla DTMF,0-9,*o#. Sin él, cada intento se bloquea antes de marcar comotenant_policy, bajo la reglatcpa.artificial_voice.optout.v1, con la decisión sellada en la cadena de auditoría como cualquier otro bloqueo (ver Errores y cumplimiento). Guardar la campaña sin la tecla se sigue aceptando; lo que se rechaza es el marcado. Fijaoptout_digiten cada campaña TTS, IVR o de encuesta que quieras seguir marcando.
La instrucción de exclusión: escríbela con el marcador {optout_digit}
El consumidor tiene que oír qué tecla lo excluye, justo después de que la llamada se identifique (47 CFR §64.1200(b)(3)). Esa frase la escribes tú, con tus palabras y en tu idioma; la plataforma pone la tecla en ella:
- Pon el marcador
{optout_digit}donde va la tecla, en tu divulgación almacenada, no la tecla misma. Cada campaña reemplaza todos los{optout_digit}por su propiooptout_digital sintetizar el mensaje, de modo que la tecla que oye el consumidor es siempre la que funciona, incluso cuando tus campañas usan teclas distintas. El marcador se compara de forma exacta: en minúsculas, con las llaves, sin espacios dentro. - Colócalo justo después de la frase que identifica la llamada como automatizada, que es lo primero que suena.
- Solo se lee la divulgación en busca del marcador. Un
{optout_digit}en eltts_promptde la campaña no se reemplaza: se leería en voz alta tal cual está escrito. - Sin el marcador la campaña no marca. La divulgación se almacena igualmente —es una divulgación válida, y
PUT /v1/artificial-voice-disclosurela acepta—, pero ninguna campaña TTS, IVR o de encuesta marca con ella, yGET /v1/campaigns/{id}/statsinformaprompt_unavailable_reason: "optout_instructions_missing". No se sintetiza nada ni se marca nada hasta que la divulgación tenga el marcador. - La tecla se comprueba primero. Una campaña sin un
optout_digitválido se sigue rechazando por intento contcpa.artificial_voice.optout.v1(arriba), con o sin marcador. La única excepción es una campaña que tiene la antigua banderaai_voice: truey ninguna tecla: se juzga como la vía de IA, así que se detiene antes de marcar con el mismooptout_instructions_missing. *y#se leen como los lea la voz. Si una campaña usa alguna de las dos, escucha cómo suena y redacta la frase en torno a ella.
Por ejemplo, en inglés:
This is an automated call from Acme Collections. Press {optout_digit} to stop receiving these calls.
Y en español:
Esta es una llamada automatizada de Acme Cobranzas. Pulse {optout_digit} para dejar de recibir estas llamadas.
Una campaña cuyo optout_digit es 8 reproduce «…Press 8 to stop receiving these calls.» antes de su tts_prompt. Cambiar la divulgación o la tecla de la campaña es un texto nuevo: la campaña lo vuelve a sintetizar y espera el audio nuevo antes de volver a marcar.
Reescribe tu divulgación antes de que esto se aplique
Esta regla se aplica desde la versión de core que la lleva. Una divulgación almacenada sin el marcador —incluida una que ya diga «pulse 9» con palabras— impide marcar a todas las campañas TTS, IVR y de encuesta de la cuenta. Reescríbela con {optout_digit} en lugar de la tecla.
Las campañas ai_voice no se juzgan por la tecla: el consumidor se excluye diciéndolo en la conversación.
Dejar un buzón de voz: compuesto, con tu número gratuito de exclusión
Desde core v0.75.0 (core#1333), una campaña tts_blast, tts_ivr, ivr_cascade o survey con detección de contestadora puede dejar un mensaje de buzón de voz, y solo uno que la plataforma componga (47 CFR §64.1200(b)(1) y (b)(3)). Ante una máquina reproduce, como un solo audio: tu divulgación almacenada (con la tecla de la campaña en lugar de {optout_digit}), luego el voicemail_message de la campaña con el marcador {optout_number} reemplazado por el número, leído dígito a dígito. Dos campos de campaña, en POST y PATCH /v1/campaigns:
voicemail_optout_number— un número gratuito de EE. UU. (800, 833, 844, 855, 866, 877 u 888) en E.164, que debe ser una ruta de exclusión activa de tu propia cuenta (dest_type: "optout"): quien lo llame de vuelta se añade a tu lista de no llamar. Un número sin esa ruta, con una ruta de otro tipo o desactivada es400 invalidnombrando el campo.voicemail_message— tu texto, de hasta 2000 caracteres, con{optout_number}donde se pronuncia el número (400 invalidsin él). Por ejemplo: «Please call us back at {optout_number} to stop receiving these calls.»
Si falta cualquier pieza —detección apagada, ningún mensaje reproducible, ningún número, ningún marcador, o la ruta de exclusión desactivada más tarde (se vuelve a comprobar en cada ronda de marcado)— no se deja ningún mensaje de buzón y se cuelga a la máquina. Todos los demás tipos de campaña cuelgan ante una máquina. El campo libre voicemail_drop_media está retirado: la API lo rechaza, y una campaña que dependía de él cuelga ante las máquinas hasta que se le den voicemail_optout_number y voicemail_message.
Campañas de IA de voz
Lo que cambió para las campañas ai_voice entre core v0.73.0 y v0.75.0, tal como lo ve tu integración:
La divulgación de la IA está lista antes de la primera llamada (core
v0.75.0, core#1327, ADR #180 de core). La divulgación de tu agente de IA en vivo se convierte en audio antes de que la campaña marque, de modo que es lo primero que oye el consumidor. Mientras corre la primera conversión,GET /v1/campaigns/{id}/statsdiceprompt_unavailable_reason: "prompt_rendering"; si falla,"ai_disclosure_render_failed", la campaña no marca y la conversión se reintenta sola.ai_disclosure_readyestruecuando el audio del texto actual está listo. Editar la divulgación la convierte de nuevo, y la campaña espera el audio nuevo. (Corev0.77.0sirveai_disclosure_render_failedyai_disclosure_ready; la Referencia de la API generada todavía no los lista.)No más llamadas de IA de las que la plataforma puede sostener (core
v0.75.0, core#1326, ADR #179 de core). La plataforma no inicia más sesiones de IA de voz de las que su entorno puede sostener. Cuando todas están en uso, la campaña espera ytenant_throttle_reasonesai_capacity: la capacidad la comparten todas las cuentas, por orden de llegada, y se libera un lugar cuando termina una llamada de IA de voz.ai_capacity_undeclaredsignifica que el entorno no ha declarado su capacidad, así que no se hace ninguna llamada de IA de voz;ai_capacity_unavailable, que la comprobación no pudo decidir, así que rechazó. Ver el freno de toda la cuenta.Una sesión rechazada por capacidad no cuelga en silencio. Si se contesta una llamada y la sesión de IA se rechaza por capacidad, la plataforma intenta primero pasarla a un agente libre. Si ninguno la toma, el consumidor oye el mensaje de identificación de llamada abandonada (el nombre del vendedor y el número de rellamada que Dialer Digital configura en tu cuenta) y la llamada termina, registrada como
abandoned: nadie, ni persona ni IA, habló con el consumidor. El mensaje se genera antes de marcar;abandon_media_readyen las estadísticas dice si está listo (lo sirve corev0.77.0, todavía no está en la Referencia de la API generada). Sin la identidad del vendedor la campaña sigue marcando, y una sesión rechazada cuelga sin el mensaje.Sin asiento libre, la conversación puede quedarse con la IA (core
v0.73.0, core#1325). Cuando la IA intenta pasar la llamada a una persona y tu cuenta no tiene ningún asiento libre, core ya no fuerza la disculpa fija y la colgada si el runtime de IA de voz pide continuar: la conversación sigue con la IA. La llamada queda comoai_conversation, y el informe de abandono de 30 días la cuenta aparte comohandoff_failed, fuera de la tasa.Una conversación con IA cuenta como conversación para Reg F (core
v0.73.0, core#1323, ADR #175 de core; 12 CFR 1006.14(b)(2)(ii)). Una llamada saliente de IA contestada que la detección no marcó como máquina inicia la espera de 7 días tras una conversación telefónica sobre la deuda, igual que una llamada con un agente: esa deuda no se vuelve a marcar dentro de la ventana. Peca de bloquear de más: un traspaso fallido o una sesión de IA perdida a mitad de la llamada también inician la espera. En la analítica de penetración estas llamadas cuentan como un contacto efectivo.La voz del agente sale de un catálogo gobernado (core#1539, ADR #199 de core; publicado en core
v0.87.0).GET /v1/ai/voiceslista las voces con las que puede hablar un agente de IA:{"voices": [{"voice_id", "provider", "language", "label"}]}, igual para todas las cuentas, legible con cualquier clavedd_;?language=filtra por el subtag principal (en-USseleccionaen) y un idioma sin voces responde200con[]. EnPOSTyPATCH /v1/ai/agents, unvoice.voice_idque no está en el catálogo es422 unprocessablenombrando el id; un cuerpo sinvoice_ides válido, y un agente guardado cuando el id era texto libre sigue funcionando: el id que ya almacena se sigue aceptando enPATCH. La divulgación de la IA se convierte en audio con la voz de ese agente, de modo que lo primero que oye el consumidor lo dice la misma voz que el resto de la llamada.Oye la voz del agente antes que un consumidor (core#1537, ADR #199 de core).
POST /v1/ai/agents/{id}/voice-previewno lleva cuerpo y responde200 {"voice_preview": {"url", "voice_id", "language", "text", "cached"}}: la voz guardada del agente leyendo una frase fija (en inglés o en español, según el idioma de la voz; no puedes enviar tu propio texto). Laurlse firma en cada respuesta y vive poco, así que pide una nueva en lugar de conservarla. El audio se genera una vez por voz y frase y se comparte, así que una petición repetida respondecached: truesin una generación nueva. Solo una petición que tiene que generar cuenta contra un presupuesto por cuenta: por encima, la respuesta es429 rate_limitedconRetry-After, y una petición en caché nunca se rechaza.404para un agente que no es tuyo,422para un agente sinvoice.voice_id, con uno fuera del catálogo (una llamada real no usaría esa voz) o con una voz cuyo idioma no tiene frase de muestra, y503 voice_preview_unavailablecon la etapa que falla enerror.reasoncuando el servicio de voz no puede producir el audio: nunca recibes un archivo vacío. Un token de navegadorddw_es403. Publicado en corev0.88.0; está en la Referencia de la API generada.La conversación de prueba del agente tiene topes (core#2421, corregido en core#2431 y voice-ai#249; core
v0.129.0, voice-aiv0.29.0).POST /v1/ai/agents/{id}/testrecibe{"message", "session_id"}(session_ides opcional: omítelo para abrir un hilo y devuelve el que recibiste para continuarlo) y responde200 {"session_id", "reply"}. Nunca es una llamada, nunca una fila deai_conversationsy nunca un marcado, y un turno rechazado tampoco lo es. Cada turno gasta el modelo de la casa, así que pasa estas compuertas antes del runtime, en este orden:400 bad_requestsi faltamessageo está vacío; simessagepasa de 4.000 bytes (message is too long (max 4000 bytes); bytes, no caracteres); y sisession_idpasa de 128 bytes (session_id is too long (max 128 bytes)). Fuente: corelib/dialer/voice_ai/sandbox_guard.ex:30-31,lib/dialer/api/v1_router.ex:15559-15566y:15600-15616.403 synthetic_tenantpara una cuenta de demostración sintética, que no gasta el modelo de la casa. Un token de navegadorddw_también es403. Fuente:lib/dialer/api/respond.ex:253-260,lib/dialer/api/v1_router.ex:15618.429 rate_limitedconRetry-After(los segundos que faltan en la ventana actual de 60 segundos) por encima de 30 turnos por ventana y cuenta, o de 12 por ventana dentro de un mismosession_id. Los límites son fijos (no se ajustan) y fallan cerrados: con el limitador caído la prueba se rechaza en vez de correr sin medir. Fuente:lib/dialer/api/mint_limit.ex:433-439,config/config.exs:238-239y:263,lib/dialer/api/v1_router.ex:15591-15598.503 sandbox_budget_unavailablecuando el freno del presupuesto de inferencia no concede la estimación del turno (una cota del prompt más la salida máxima del modelo): no se envía nada al modelo. Reintenta más tarde. Fuente:lib/dialer/voice_ai/sandbox_guard.ex:113-134,lib/dialer/api/v1_router.ex:15621-15628.
Pasadas las compuertas,
503 sandbox_unavailablesigue significando que el runtime de IA no está configurado o no responde, y502 bad_gatewayque falló o contestó algo inesperado. Dentro de una sesión el runtime conserva unos 24.000 bytes de historial (los turnos más antiguos caen primero y las instrucciones del agente se quedan), olvida una sesión inactiva durante 900 segundos y guarda como máximo 256 sesiones, desalojando la menos usada. Fuente: voice-aisrc/voiceai/sandbox.py:47-50y:119-125.Pasar a
liveexige guardas explícitas (core#2459, ADR #298 de core; core CHANGELOG, 0.130.0).PATCH /v1/ai/agents/{id}constatus: livecomprueba que lapolicydel agente traigamax_settlement_pct,min_payment_centsymax_plan_months, y al menos una frase no vacía enprohibited; contake_payments: "card_on_file"pide además una entrada dehandoff_rulescuyotriggerseapayment_over. El ajuste de la plataformaai_agent_live_requires_guards(off,observeoenforce; por defectoobserve) decide qué hace un hueco:observe:200como siempre, conwarnings: [{"code": "policy_incomplete", "message", "missing": [...]}], dondemissinglista las claves ausentes entremax_settlement_pct,min_payment_cents,max_plan_months,prohibitedypayment_over.enforce:422con{"error": {"code": "policy_incomplete", "message", "missing": [...]}}. Rechaza el paso alivey la edición de un agente en vivo que añade un hueco que no tenía; un agente en vivo heredado sin guardas no se corta y recibewarnings.off: sin comprobación y sinwarnings.
Envía las claves en el mismo
PATCHo antes, y leewarningsen cada200de un agente en vivo. Fuente: core v0.134.0lib/dialer/voice_ai.ex:198-213,:215-287,lib/dialer/api/respond.ex:337-345,lib/dialer/api/v1_router.ex:10822. La consola muestra el aviso y el 422 con las claves que faltan (dashboard#1344).take_payments: "card_on_file"necesita el ajuste del tenantai_take_payments_enabled(core#2463). El ajuste está apagado por defecto: nada cobra dinero todavía, y la IA solo registra un compromiso de pago. Mientras está apagado, una escritura que pongapolicy.take_paymentsencard_on_fileen un agente que no lo guarda ya se rechaza con422(policy.take_payments card_on_file is not available: no payment provider is connected (ai_take_payments_enabled is off); use none). Un agente ya guardado concard_on_filese puede seguir guardando con él, y la plataforma lo sirve al runtime de voz comononehasta que se encienda el ajuste. Envíanoneen su lugar. La consola muestra «Cobrar con tarjeta guardada no está disponible. Deja Cobrar pagos en No.» y ofrece la opción de tarjeta solo a un agente que ya la guarda (dashboard#1477). Fuente: corelib/dialer/voice_ai.ex:326-355,lib/dialer/api/respond.ex:358-369,lib/dialer/flags/catalog.ex:1256,openapi/dialer-v1.yaml:32281.
GET /v1/campaigns/{id} / PATCH /v1/campaigns/{id}
Obtiene / actualiza los campos de configuración de arriba. PATCH devuelve 200 {"campaign": {...}}; state no es un campo aceptado: enviarlo hace fallar la petición con 400 bad_request nombrando la clave. Usa los endpoints del ciclo de vida de abajo.
Ciclo de vida: start / resume / pause / stop / archive
POST /v1/campaigns/{id}/start
POST /v1/campaigns/{id}/resume # alias of start (paused -> running)
POST /v1/campaigns/{id}/pause
POST /v1/campaigns/{id}/stop
POST /v1/campaigns/{id}/archivestartpersisterunningPRIMERO y después arranca el runner del bucle de marcado. Es idempotente si ya está en marcha. Opciones opcionales del runner en el cuerpo, validadas fail-closed (400 bad_requestante tipos erróneos):json{ "tick_ms": 1000, "batch_size": 10, "gateway": "default", "agent_domain": "agents.local", "call_ttl_ms": 60000, "blocked_cooldown_ms": 300000 }ai_disclosure_configuredya no se acepta aquí: el schema es cerrado y enviarlo es400 bad_request. La divulgación de voz artificial de la vía NO-IA se almacena una vez conPUT /v1/artificial-voice-disclosurey se resuelve por intento, en vez de afirmarse en el arranque.pausepersistepausedy detiene el runner; las llamadas en curso terminan por el pipeline del CDR.stoppersistecompleted(terminal) y detiene el runner.archivesolo desdedraftocompleted.
Las transiciones no válidas → 409 conflict. Todas las respuestas del ciclo de vida: 200 {"campaign": {...}}.
GET /v1/campaigns/{id}/stats
Contadores en vivo del runner; stats es null cuando el runner no está en marcha:
json
{
"campaign_id": "c1a2b3c4-d5e6-7f80-91a2-b3c4d5e6f708",
"state": "running",
"running": true,
"stats": {
"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
}
}
}La misma carga se empuja cada ~2 s por el WebSocket como campaign.stats.
Una campaña running que no marca: el freno de toda la cuenta
Algunos frenos detienen todas las campañas de la cuenta a la vez: límites de facturación y de capacidad, nunca de cumplimiento. Desde core v0.73.0 (core#1320) las estadísticas dicen cuál, en lugar de una campaña running con todos los contadores en 0:
json
{
"tenant_throttled": 120,
"tenant_throttle_reason": "exposure_limit",
"tenant_throttle_by_reason": { "exposure_limit": 120 },
"tenant_throttle_last_at": "2026-09-29T16:58:00Z"
}tenant_throttled— rondas de marcado detenidas por un freno así, acumuladas para el runner.tenant_throttle_reason— el freno vigente ahora, onullcuando no hay ninguno. Una ronda que hace una llamada y no termina en un freno lo borra.tenant_throttle_by_reason—tenant_throttleddesglosado por motivo.tenant_throttle_last_at— cuándo un freno detuvo una ronda por última vez; se conserva después de que el freno se levante.
tenant_throttle_reason | Qué detuvo el marcado |
|---|---|
plan_inactive | El plan de la cuenta no está activo. |
spend_cap / account_spend_cap | Se alcanzó el presupuesto de IA de voz del mes: el de esta cuenta, o el de la empresa sumado entre todas sus cuentas. |
wallet_insufficient | El saldo prepagado no cubre una llamada. |
plan_exhausted | Se agotó el crédito pospago, o la asignación incluida del plan para el periodo. |
exposure_limit | Se alcanzó el techo de gasto que rige mientras la verificación de identidad (KYC) de la cuenta está pendiente; crece cada día. |
tenant_cps / tenant_channels / carrier_cps | El límite de llamadas por segundo o de llamadas simultáneas de la cuenta, o el límite de llamadas por segundo del operador telefónico. |
capacity_unavailable | La comprobación de capacidad no pudo decidir, así que rechazó. |
ai_capacity / ai_capacity_undeclared / ai_capacity_unavailable | Solo campañas de IA de voz: ver campañas de IA de voz. |
La campaña sigue running y sigue intentándolo por sí sola; vuelve a marcar cuando el freno se levanta. No son bloqueos de cumplimiento: no dejan fila de evidencia y no aparecen en GET /v1/stats/blocked. throttled es un contador distinto: el freno de ritmo de la propia campaña.
Rewind de campaña (reencolado por el operador)
Reencola los leads agotados para que el runner los trate como nunca intentados. El CDR no se toca —es de solo anexado, los contadores de Reg F siguen contando.
Vista previa (simulacro)
POST /v1/campaigns/{id}/rewind/preview # filters in the JSON body
GET /v1/campaigns/{id}/rewind/preview?dispositions=no_answer,busy&max_attempts=3&last_attempt_older_than_days=7json
{
"matching": 184,
"excluded_by_compliance": 12,
"debt_ids": ["..."]
}excluded_by_compliance = deudas coincidentes que el motor bloquearía ahora mismo (Reg F / DNC / horas de silencio). Es informativo: las compuertas se vuelven a aplicar en cada originate de todos modos.
Ejecutar
POST /v1/campaigns/{id}/rewindjson
{
"dispositions": ["no_answer", "busy"],
"amd_verdicts": ["machine", "human"],
"max_attempts": 3,
"last_attempt_older_than_days": 7,
"keep_scheduled_callbacks": true
}json
{ "requeued": 172, "callbacks_cancelled": 0 }Reglas:
- Filtros:
dispositions(se compara con la disposición más reciente de la deuda),amd_verdicts(se compara con el último intento realizado),max_attempts(cantidad de intentos originales),last_attempt_older_than_days. keep_scheduled_callbacksvaletruepor defecto;falsecancela las rellamadas pendientes de la campaña para las deudas reencoladas.- Fija una marca de agua
requeued_atpor deuda y resucitaclosed→open. Las deudas enpaid/settled/disputednunca se reencolan. amd_verdictsaceptaunspecified | human | machine | not_sure; un valor desconocido devuelve422 unprocessable(fail-closed, nunca se ignora en silencio). Los tipos de filtro erróneos devuelven400.- En lugar de filtros sueltos puedes enviar
{"segment_id": "..."}: un segmento guardado. Lo aceptan tanto la vista previa (cuerpo dePOSTyGET ?segment_id=) como la llamada de ejecución. Mezclarsegment_idcon cualquier filtro es400; un segmento desconocido, retirado o de otro tenant es404.
Segmentos de rewind guardados
Un supervisor que repite el mismo rewind cada semana puede guardar sus filtros bajo un nombre. Un segmento guarda los filtros, nunca una lista de cuentas: usarlo lo vuelve a evaluar contra los datos de ese momento, así que no hay una segunda copia de datos personales que se quede obsoleta o se filtre. Publicado en core v0.87.0 (core#1554), y en la Referencia de la API.
POST /v1/rewind-segmentsjson
{
"name": "Voicemail, 3+ attempts, last week",
"filters": { "dispositions": ["no_answer", "voicemail"], "max_attempts": 3, "last_attempt_older_than_days": 7 }
}json
{
"rewind_segment": {
"id": "…", "name": "Voicemail, 3+ attempts, last week",
"filters": { "dispositions": ["no_answer", "voicemail"], "max_attempts": 3, "last_attempt_older_than_days": 7 },
"created_by": "…", "created_at": "…", "updated_at": "…", "retired_at": null, "retired_by": null
}
}Después haz el rewind con él:
POST /v1/campaigns/{id}/rewind
{ "segment_id": "…" }Reglas:
filterspasa por el mismo validador que el propio rewind: un conjunto de filtros que el rewind rechazaría es422 unprocessableal guardar, y una clave no reconocida es422nombrándola. Un segmento cuyos filtros guardados dejan de validar responde422al usarse; nunca un rewind más amplio.namese recorta, de 1 a 80 caracteres, y es único entre los segmentos vigentes del tenant sin distinguir mayúsculas (409 conflicten caso contrario). Un segmento retirado libera su nombre.GET /v1/rewind-segmentslista los segmentos vigentes.PATCH /v1/rewind-segments/{id}renombra y/o reemplazafilters(sin fusionar); una edición que no cambia nada no escribe nada.POST /v1/rewind-segments/{id}/retirees lógico y terminal: no se borra nada, el segmento sale de la lista y no se puede volver a usar ni editar (404).- Cada guardado, edición y retiro deja un hecho de auditoría de solo anexado (quién, cuándo, y el nombre y los filtros resultantes).
- Los segmentos son por tenant: el segmento de otro tenant es
404.
Deudas / leads
Plantillas de mapeo de importación
/v1/import-mappings guarda en el servidor, por tenant, el mapeo de columnas del CSV, de modo que un equipo que importa el mismo archivo cada semana conserva su mapeo entre navegadores y personas.
- Crear
POST /v1/import-mappingsconname(único entre las plantillas vigentes del tenant,409en caso contrario),mapping(una lista ordenada de{column, field}; al menos una entrada debe mapear a algo distinto deskip) yoptionsopcional (separator:comma|semicolon|tab;default_currency: tres letras mayúsculas). El servidor comprueba la forma defield, no un vocabulario cerrado: cada consumidor falla en modo cerrado ante un campo que no conoce. Un tenant tiene como máximo 500 plantillas vigentes y una plantilla, como máximo, 1 000 versiones (422). - Las versiones son de solo anexado.
PATCHcon unmappinguoptionsdistintos escribe la versión n+1 y deja la versión n exactamente como estaba; una edición que no cambia nada no escribe versión. Envíabase_versionpara que se rechace la edición (409) si alguien editó antes. - Archivar, nunca borrar.
DELETEarchiva (idempotente): el nombre queda libre y la plantilla ya no se puede editar ni nombrar en una importación. Sus versiones siguen siendo legibles. - Usarla en una importación. Pasa
?mapping_id=<id>enPOST /v1/debts/importo enPOST /v1/debt-lists/{id}/import; la importación registra qué versión usó y cuántos elementos entraron y fallaron, legible enGET /v1/import-mappings/{id}/uses. En la subida multipart,mapping_idsustituye a las partesmappingyseparator: envía una cosa o la otra; enviar las dos se rechaza.
POST /v1/debts/import
Importación masiva en JSON: idempotente, tolerante a fallos por elemento, máximo 10 000 elementos por petición. Las deudas hacen upsert por external_ref; los contactos, por phone_e164.
json
{
"debts": [
{
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"debt_type": "other",
"account_number": "4111-XXXX",
"debtor_name": "Ana María Pérez",
"amount_cents": 50000,
"currency": "USD",
"campaign_id": "c1a2b3c4-d5e6-7f80-91a2-b3c4d5e6f708",
"contacts": [
{
"phone_e164": "+13125551001",
"line_type": "landline",
"timezone": "America/Chicago",
"us_state": "IL",
"city": "Chicago",
"is_primary": true
}
]
}
]
}json
{ "imported": 1, "failed": [] }external_ref(obligatorio) es la clave del contador de Reg F: los límites de frecuencia cuentan por deuda, así que hazlo estable y único por cuenta.consumer_ref(obligatorio) es la clave de la persona: la analítica de penetración deduplica por ella.phone_e164debe estar en E.164 (lo impone un CHECK de la base de datos);line_type∈mobile|landline|voip|unknown.debt_type∈other|student_loan.debtor_name(opcional) es el nombre del deudor como texto libre. Los espacios al principio y al final se recortan y las rachas de espacios se colapsan en uno; un valor en blanco se guarda como sin nombre. El límite es de 200 caracteres tras eso: un nombre más largo hace fallar ese elemento con"debtor_name should be at most 200 character(s)"(el error nunca repite el valor), y un valor que no es texto falla con"debtor_name is invalid". Se guarda cifrado y no se puede buscar: no hay filtro por él y no aparece en la listaGET /v1/debts. La subida CSV multipart lo toma como el destinodebtor_namedelmappingy lo valida por fila igual que la importación JSON. Una importación es una instantánea del elemento: volver a importar una deuda (mismoexternal_ref) sindebtor_nameborra el nombre que tenía, como todos los demás campos del elemento. Publicado en corev0.87.0(core#1529), y en la Referencia de la API.- Los fallos vuelven por elemento:
{"failed": [{"index": 3, "error": "..."}]}; el resto del lote se importa igualmente. - La subida CSV multipart no está implementada: el mapeador de CSV del dashboard convierte en el cliente y envía este JSON.
Semillas de Reg F en la importación
Dos campos opcionales en cada elemento permiten a un cliente que migra una cartera desde otro marcador declarar el historial de Reg F que esta plataforma no podría conocer de otro modo, para que las compuertas de frecuencia y de enfriamiento arranquen con el historial real del cliente en lugar de desde cero. Ambos son por elemento y tolerantes a fallos, como cualquier otro campo de esta ruta; omitir cualquiera de los dos no cambia nada.
prior_attempts_7d(entero ≥ 0): cuántas llamadas sobre esta deuda hizo ya el cliente desde su sistema anterior en los últimos 7 días. Se materializa como filas de intento que la compuerta ya lee, de modo que el contador 7 en 7 (reg_f.attempts_in_windowen la ficha del deudor) arranca desde ese número. Es un total de la ventana, no un delta: reenviar la misma carga no suma nada, y reenviar un número menor nunca retracta el historial: la cuenta solo sube, igual quelast_conversation_at. Un valor superior a 7 se guarda como 7 (7 ya satura todos los topes de frecuencia que tiene el motor). Un valor que no es un entero ≥ 0 hace fallar ese elemento.last_conversation_at(RFC 3339 con desfase UTC, nunca en el futuro): cuándo habló el cliente por última vez con el deudor en su sistema anterior. Siembra una fila deconversationsconsource: cms_importy adelanta el anclalast_conversation_atde la deuda, de modo que el enfriamiento de 7 días tras el contacto (12 CFR 1006.14(b)(2)(i)(B)) bloquea la siguiente llamada dentro de la ventana en lugar de arrancar a ciegas. Reenviar la misma carga no suma nada, y una fecha más antigua nunca retrocede el ancla. Un valor mal formado, sin desfase UTC o en el futuro hace fallar ese elemento; estas conversaciones no cuentan como actividad de la plataforma en penetración, abandono ni en la exportación de intentos. Publicado en corev0.90.0(core#1633, ADR #212), y en la Referencia de la API.
En la subida CSV los mismos dos campos son destinos del mapeo con los mismos nombres —prior_attempts_7d y last_conversation_at—, mapeados a una cabecera como cualquier otro destino de deuda:
json
{ "external_ref": "account", "prior_attempts_7d": "attempts_7d", "last_conversation_at": "last_call", "contact.phone_e164": "phone" }Una columna mapeada a last_conversation_at pasa por la misma validación que el campo JSON y siembra la misma conversación cms_import; una celda errónea hace fallar solo su fila, y una celda en blanco equivale a omitir el campo.
json
{
"debts": [
{
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"prior_attempts_7d": 3,
"last_conversation_at": "2026-10-03T14:00:00-05:00",
"amount_cents": 50000,
"currency": "USD",
"contacts": [ { "phone_e164": "+13125551001", "is_primary": true } ]
}
]
}GET /v1/debts/{id}
json
{
"debt": {
"id": "d0e1f2a3-...",
"tenant_id": "0a1b2c3d-...",
"campaign_id": "c1a2b3c4-...",
"external_ref": "demo-1001",
"consumer_ref": "consumer-1001",
"debt_type": "other",
"account_number": null,
"debtor_name": "Ana María Pérez",
"amount_cents": 50000,
"currency": "USD",
"state": "open",
"last_conversation_at": null,
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z",
"contacts": [
{
"id": "e1f2a3b4-...",
"debt_id": "d0e1f2a3-...",
"consumer_ref": "consumer-1001",
"phone_e164": "+13125551001",
"line_type": "landline",
"timezone": "America/Chicago",
"us_state": "IL",
"city": "Chicago",
"is_primary": true
}
]
}
}debtor_name es null cuando la importación no traía nombre. Se lee aquí, en la ficha del deudor y, para las llamadas del propio agente, en GET /v1/calls del widget; no viaja a ClickHouse, a los webhooks salientes, al paquete de defensa para litigio ni a los logs.
GET /v1/debts/{id}/call_attempts
El historial durable completo de una deuda: los intentos originales más las correcciones compensatorias, en una sola lista. La forma de cada fila es la de Consulta del CDR.
GET /v1/consumers/{id}
La ficha del deudor. {id} es el consumer_ref que tu CMS envió en la importación (de 1 a 128 caracteres, no en blanco, sin byte NUL); la plataforma no tiene un registro de consumidores aparte, y consumer_ref es la clave que el contador de Reg F ya usa. La respuesta es {"consumer": {...}}:
json
{
"consumer": {
"id": "consumer-1001",
"identity": "consumer_ref",
"generated_at": "2026-10-04T15:00:00Z",
"debts": [
{
"id": "d0e1f2a3-...",
"external_ref": "demo-1001",
"debtor_name": "Ana María Pérez",
"state": "open",
"primary_phone_masked": "***1001",
"reg_f": {"scope": "debt", "attempts_in_window": 2, "cap": 7, "window_days": 7,
"last_conversation_at": null, "status": "ok", "eligible_at": "2026-10-04T15:00:00Z"},
"window": {"status": "permitted_now", "reason": null, "at": "2026-10-04T15:00:00Z",
"destination_timezone": "America/Chicago",
"destination_local_time": "2026-10-04T10:00:00-05:00"}
}
],
"debts_truncated": false,
"cease_and_desist": {"on_file": false, "records": []},
"dnc": {"contacts_total": 1, "contacts_checked": 1, "contacts_listed": 0,
"lists": {"federal": 0, "state": 0, "internal_tenant": 0, "internal_global": 0},
"truncated": false},
"next_window": {"status": "permitted_now", "reason": null, "at": "2026-10-04T15:00:00Z",
"destination_timezone": "America/Chicago",
"destination_local_time": "2026-10-04T10:00:00-05:00"},
"evaluated_gates": ["regf.7in7.v1", "state.matrix.v1", "fdcpa.quiet_hours.v1"],
"not_evaluated_gates": ["tcpa.consent.v1", "dnc.lists.v1 (provisioning and RND freshness)",
"ai_disclosure", "artificial_voice", "campaign state"]
}
}(El ejemplo está abreviado: cada deuda lleva todos los campos del resumen de la lista GET /v1/debts, más reg_f y window.)
- Derivada, no almacenada. No se acumula nada para la ficha. Se calcula en cada petición a partir de los registros de solo anexado de llamadas, conversaciones, cese de comunicaciones y DNC, y
generated_ates el instante del reloj de cumplimiento en el que se evaluó cada cifra. - La ventana es un veredicto de las comprobaciones reales.
evaluated_gateslista las reglas dependientes del tiempo que hay detrás dewindowynext_window;not_evaluated_gateslista lo que no cubren, de modo quepermitted_nowno es una promesa de que una campaña vaya a marcar el número.statusespermitted_now,scheduled(el primer instante permitido enat, buscado a lo largo de los próximos 9 días),suppressed(una inclusión en DNC o un cese de comunicaciones prohíbe el contacto),not_dialable(el estado de la deuda no es marcable) oundecidable(la ficha no puede decidir y da elreason; nunca se lee como permiso). - El nombre del deudor. Cada deuda lleva
debtor_name: el nombre que la importación envió para esa cuenta, onull. Se descifra solo para esta respuesta; la listaGET /v1/debtsno lo lleva. - Sin números de teléfono. El estado de DNC son cuentas por lista. El teléfono de cada deuda va enmascarado (
***+ los cuatro últimos), como enGET /v1/debts. - Errores.
400 bad_requestpara uniden blanco, de más de 128 caracteres o con un byte NUL;404 not_foundpara un consumidor sin deudas en tu tenant, que es también lo que recibes por unconsumer_refque existe solo en otro tenant.
Listas de deudas: miembros
Una lista reutilizable (/v1/debt-lists) guarda deudas; importar en ella nunca saca ninguna. Dos verbos leen y sacan sus miembros (una deuda que no es miembro no saca nada y no es error):
GET /v1/debt-lists/{id}/memberslee los miembros, del que entró antes al último, en páginas de hasta 500 (?limit=, un valor mayor se recorta) con un?cursor=opaco (elnext_cursorde la página anterior;nullen la última). Cada fila traedebt_id, elexternal_refdel cliente yadded_at: ni teléfono ni nombre del deudor. Una lista archivada se sigue pudiendo leer;404si la lista no es tuya.DELETE /v1/debt-lists/{id}/memberssaca hasta 500 deudas en una llamada, con el cuerpo{"debt_ids": ["…"]}(un cuerpo en unDELETE) y responde200 {"removed": N}, la cuenta de las que de verdad salieron.400sidebt_idsestá vacío, tiene más de 500 o trae un valor que no es UUID, y entonces no sale ninguna.DELETE /v1/debt-lists/{id}/members/{debt_id}es el gemelo de una sola deuda.
Las deudas no se borran; solo dejan de ser miembros y, con ello, de ser trabajadas por las campañas que las tenían por esta lista (salvo que estén en ellas por otra vía). La plataforma no guarda qué importación metió cada deuda, así que no existe «deshacer esta importación»: lee los external_ref y sácalos en lotes.
Consulta del CDR
GET /v1/call_attempts
La tabla de intentos de llamada, de solo anexado, es la verdad del CDR de la plataforma.
Parámetros de consulta:
| Parámetro | Tipo | Notas |
|---|---|---|
campaign_id, debt_id, agent_id | UUID | Filtros. Valores que no son UUID → 400. |
disposition | string | Coincidencia exacta. Valores del motor: answered_human, abandoned, delivered, ai_conversation, answered_machine, ai_stream_lost, busy, no_answer, canceled, rejected, failed, además de resultados tipados del agente o de la IA como promise_to_pay. Un código desconocido devuelve 200 sin filas. Ver llamadas contestadas sin puente. |
from, to | RFC 3339 | Límites sobre started_at. |
limit | int | Por defecto 100, máximo 1000. |
cursor | string | Cursor keyset opaco de la página anterior. |
json
{
"call_attempts": [
{
"id": "a1b2c3d4-...",
"tenant_id": "0a1b2c3d-...",
"debt_id": "d0e1f2a3-...",
"campaign_id": "c1a2b3c4-...",
"agent_id": "f0e1d2c3-...",
"consumer_ref": "consumer-1001",
"debt_key": "debt:demo-1001",
"call_uuid": "b7c8d9e0-...",
"from_number": "+13125550199",
"to_number": "+13125551001",
"started_at": "2026-06-11T14:29:31Z",
"answered_at": "2026-06-11T14:29:42Z",
"ended_at": "2026-06-11T14:33:05Z",
"disposition": "answered_human",
"hangup_cause": "NORMAL_CLEARING",
"sip_response_code": 200,
"compliance_snapshot": { "...": "frozen decision evidence" },
"corrects_id": null,
"inserted_at": "2026-06-11T14:33:05Z"
}
],
"next_cursor": "g3QAAAAC..."
}Semántica:
- Las filas son de solo anexado. Los originales tienen
corrects_id: null; las correcciones (disposiciones tipadas) apuntan al original. La última corrección es la disposición final de un intento. compliance_snapshotlleva la evidencia congelada de la decisión de cumplimiento que permitió el marcado: tu pista de auditoría, inmune a cambios posteriores en los datos.
Llamadas contestadas sin puente: con el nombre de lo que fueron (cambio incompatible en core v0.73.0)
Hasta core v0.73.0, toda llamada saliente que se contestaba y nunca se puenteaba a un agente se registraba como abandoned. Desde v0.73.0 (core#1323, ADR #175 de core) se nombra por lo que ocurrió:
| Disposición | Cuándo |
|---|---|
answered_machine | La detección de contestadora dijo máquina, sea cual sea el tipo de campaña. |
ai_conversation | Una campaña de IA de voz: el consumidor habló con la IA. Un traspaso a un agente que no encontró asiento libre sigue siendo ai_conversation. |
delivered | Una campaña TTS, IVR o de encuesta: se entregó el mensaje grabado o el menú. |
abandoned | Solo cuando se esperaba a un agente y ninguno tomó la llamada. |
- Solo hacia adelante. Las filas escritas antes de
v0.73.0conservanabandoned. Un informe, filtro o exportación que buscabaabandonedpara encontrar mensajes entregados o conversaciones de IA debe buscar los códigos nuevos desde esa versión, y conservarabandonedpara las filas más antiguas. - Tus reglas de reintento vinieron con ello. La actualización copió cada regla que habías escrito para
abandonedadelivered,ai_conversationyanswered_machine, con los mismos ajustes, salvo que ya tuvieras tu propia regla para ese código; cada copia está en el historial de tus reglas y puede eliminarse como cualquier regla. Una cuenta con las cadencias recomendadas de la plataforma ve ahora su regla deanswered_machineaplicarse a las máquinas alcanzadas por el marcador predictivo, que antes caían bajoabandoned. - La puntuación de contacto no cambia: los códigos nuevos puntúan exactamente igual que el
abandonedal que sustituyen.
GET /v1/reports/cdr
Las mismas filas que GET /v1/call_attempts, en el protojson canónico de dialer.v1.CallDetailRecord. Toma exactamente los mismos parámetros de consulta (filtros, limit, cursor), pagina con el mismo cursor keyset y se aísla por tenant de la misma manera; solo cambia la forma en el cable. Se publicó en core v0.87.0 (core#1472) y está en la Referencia de la API.
json
{
"callDetailRecords": [
{
"callId": "0190a1b2-0000-7000-8000-000000000001",
"tenantId": "0190a1b2-0000-7000-8000-0000000000aa",
"toNumber": "+13125550100",
"startedAt": "2026-10-04T12:00:00.250Z",
"answeredAt": "2026-10-04T12:00:05.250Z",
"endedAt": "2026-10-04T12:01:05.250Z",
"durationSeconds": 65,
"billsec": 60,
"disposition": "DISPOSITION_ANSWERED_HUMAN",
"hangupCause": "NORMAL_CLEARING",
"sipResponseCode": 200,
"recordVersion": 1
}
],
"nextCursor": "opaque"
}- Protojson, no la forma de
/v1/call_attempts. Las claves van en lowerCamelCase, los enums son sus nombres (DISPOSITION_ANSWERED_HUMAN), las marcas de tiempo son RFC 3339 en UTC y un campo en su valor por defecto de proto3 se omite. Por esonextCursorestá ausente en la última página, donde/v1/call_attemptsrespondenext_cursor: null. - Nada más allá de la consulta del CDR. El registro lleva solo lo que
/v1/call_attemptsya publica. Los campos del contrato sin origen en un intento de llamada (direction,mediaPath, los campos del operador telefónico,attestation,costWeight) se omiten, no se adivinan. - Una fila es un registro. La tabla es de solo anexado, así que una corrección es su propio registro; una fila sin UUID de canal usa el id de su intento como
callId, y una corrección lleva el id del original que corrige. - El enum de disposición es cerrado, el vocabulario REST es abierto. Un código que está en el enum del contrato va en
disposition; cualquier otro (abandoned,delivered,promise_to_pay, …) se deja fuera dedispositiony viaja enlabels.disposition_code. El filtrodispositionsigue coincidiendo con el valor REST.
Paginación: el patrón keyset
Los endpoints de lista que pueden crecer sin límite (/v1/call_attempts) usan cursores keyset, no desplazamientos:
- Pide con
limit(≤ 1000). La respuesta incluyenext_cursorcuando la página venía llena, ynullcuando llegaste al final. - Pasa
cursor=<next_cursor>(tal cual, es opaco) con los mismos filtros para obtener la página siguiente. - Repite hasta que
next_cursorseanull.
bash
curl -s "$API/v1/call_attempts?campaign_id=$CID&limit=500" -H "Authorization: Bearer $TOKEN"
curl -s "$API/v1/call_attempts?campaign_id=$CID&limit=500&cursor=$NEXT" -H "Authorization: Bearer $TOKEN"La paginación keyset es estable ante inserciones concurrentes: nunca te saltas ni lees dos veces filas porque hayan entrado llamadas nuevas mientras paginabas. GET /v1/callbacks pagina igual en su vista de cola (status=…): next_cursor vuelve como cursor, opaco, y es null en la última página. El feed due=now no se pagina. promises toma limit (máximo 1000) y aún no se pagina.
Listas con tope: truncated
Algunas listas no se paginan: core devuelve hasta un número fijo de filas y lo dice. GET /v1/users (500 usuarios y 500 invitaciones abiertas), GET /v1/agents (asientos, 2 000), GET /v1/campaigns (2 000), GET /v1/dids (números, 2 000) y GET /v1/debt-lists (listas, 2 000) responden un booleano truncated junto al array. truncated: true significa que había más filas que el tope: lo que recibiste es un prefijo en el orden documentado, no el conjunto entero, y no hay cursor para pedir el resto. Nunca leas como completa una lista con truncated: true: muestra el corte al usuario o acota la petición.
Disposiciones tipadas
POST /v1/call_attempts/{id}/disposition
Registra un resultado introducido por el agente o la IA para un intento terminado como un registro CDR compensatorio: la tabla sigue siendo de solo anexado; nada se sobrescribe jamás.
{id} debe ser una fila original (corrects_id: null); las correcciones y los ids desconocidos son 404.
json
{
"disposition": "promise_to_pay",
"agent_id": "f0e1d2c3-...",
"promise": {
"amount_cents": 25000,
"currency": "USD",
"promised_date": "2026-06-21",
"note": "weekly installment agreed on the call"
}
}201:
json
{
"call_attempt": { "...": "the correction row, corrects_id = the original id" },
"promise": {
"id": "9a8b7c6d-...",
"tenant_id": "0a1b2c3d-...",
"debt_id": "d0e1f2a3-...",
"call_attempt_id": "a1b2c3d4-...",
"agent_id": "f0e1d2c3-...",
"amount_cents": 25000,
"currency": "USD",
"promised_date": "2026-06-21",
"status": "pending",
"note": "weekly installment agreed on the call",
"created_at": "2026-06-11T14:34:00Z",
"updated_at": "2026-06-11T14:34:00Z"
}
}Atestación del aviso de cobranza
Estado de publicación: estos contratos están entregados en
mainde core (core#1587, ADR #211), después de corev0.87.0. La Referencia de la API generada de este sitio sigue las versiones publicadas y todavía no los lista.
POST /v1/call_attempts/{id}/disclosure anexa la declaración del agente sobre el aviso de cobranza. No hay un resultado por defecto:
json
{
"agent_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"outcome": "not_delivered",
"reason": "consumer_hung_up"
}outcome es delivered o not_delivered. Este último exige un reason del vocabulario cerrado del contrato; reason: other puede llevar una note acotada. script_sha256 puede identificar el texto del aviso mostrado al agente. Para corregir la declaración vigente, anexa otra con su supersedes_id; las filas no se editan ni se borran. La respuesta es 201 con el DisclosureEvent bajo disclosure.
Los mismos campos, sin agent_id, pueden viajar como el campo opcional de nivel superior disclosure de POST /v1/call_attempts/{id}/disposition: ahí la atestación toma al autor de la disposición, y cualquier otra clave (incluida agent_id) es un 400 que no escribe nada. La atestación, la corrección de disposición y cualquier promesa se confirman en una sola transacción. Una atestación rechazada no escribe ninguna de las tres.
GET /v1/call_attempts devuelve disclosure.status con uno de estos valores: attested, declared_not_delivered, unattested, not_applicable o undecidable. Devuelve además el event_id vigente, su marca de tiempo del servidor en at, y revised. GET /v1/reports/attempts anexa el mismo estado como la columna CSV disclosure_status. Esa exportación también acepta campaign_id (un uuid; uno mal formado es un 400) y disposition (se compara exacta con la disposición final que el CSV imprime en outcome), igual que GET /v1/call_attempts. Un cursor solo vale con los filtros con los que se produjo: retoma una exportación filtrada con los mismos campaign_id y disposition. La versión 2 del paquete de defensa lleva cada entrada de disclosure_events, incluidas las filas reemplazadas después, un resumen y el estado de cada intento.
Estos registros dicen lo que el agente declaró. No prueban que el aviso se pronunciara, se oyera o se entendiera, ni que su redacción satisfaga una norma. El tenant y su asesoría legal son dueños del texto propio de cada jurisdicción; la API puede conservar su huella sin aprobar su contenido.
Errores de aplicación obligatoria
La clave gobernada disclosure_attestation_enforced empieza en false. Cuando es true, cerrar una llamada que tiene una deuda efectiva y un resultado final del pipeline answered_human o transferred exige una atestación anterior o el campo disclosure del cuerpo de la disposición:
| HTTP | error.code | Acción del cliente |
|---|---|---|
422 | disclosure_attestation_required | Recoge la declaración del agente y reintenta la disposición con disclosure, o anéxala antes por la ruta dedicada. No se escribió nada del cierre rechazado. |
409 | attempt_not_final | La fila final del pipeline todavía no existe. Reintenta cuando termine la llamada; no trates la llamada como fuera de alcance. |
Una llamada sin deuda efectiva queda fuera del requisito. Apagar la clave solo quita el bloqueo al cerrar; nunca crea una atestación.
Guía del operador
La clave es un ajuste de seguridad con writable_by: founder; un operador normal de la plataforma no puede cambiarla. Empieza apagada para que core pueda desplegarse antes de que el espacio de trabajo del agente tenga la acción de atestación. Habilítala para un tenant solo después de desplegar esa interfaz:
- Lee
GET /v1/admin/tenants/{id}/configy localizadisclosure_attestation_enforced; confirma que se respeta el override. - Con la credencial de administración autorizada por el founder, envía
PUT /v1/admin/tenants/{id}/config/disclosure_attestation_enforcedcon{ "value": true }. - Para levantar el requisito, envía el mismo
PUTcon{ "value": false }, o hazDELETEdel override para volver al valor por defecto del catálogo (false). Ninguna de las dos operaciones cambia los eventos de divulgación existentes. - Lee
GET /v1/admin/tenants/{id}/config-audit, de la más reciente a la más antigua, y selecciona las filas cuyakeyseadisclosure_attestation_enforced. Para un apagado, leeold_value,new_value,active,actor,outcomeychanged_at.
La pista de configuración es de solo anexado a nivel de base de datos y registra tanto los cambios aplicados como los intentos rechazados. Es independiente de la cadena de cumplimiento sellada que cubre los eventos de divulgación por llamada.
El contrato de promesa de pago (422)
El contrato tipado es bidireccional y fail-closed:
| Lo que envías | Resultado |
|---|---|
"disposition": "promise_to_pay" con una carga promise válida | 201: la corrección y la promesa se confirman en una transacción; el estado de lead de la deuda avanza a promise_to_pay (sigue siendo marcable). |
"disposition": "promise_to_pay" sin promise | 422 unprocessable: una PTP sin términos no es una PTP. |
Cualquier otra disposición con una carga promise | 422 unprocessable: las promesas solo se adjuntan a promise_to_pay. |
Reglas de la carga de la promesa: amount_cents > 0; currency ∈ MXN | USD; promised_date debe estar en el futuro, como máximo a 90 días. disposition es cualquier cadena no vacía; agent_id es opcional y debe ser un UUID (400 en caso contrario).
Promesas (pipeline de PTP)
GET /v1/promises
Parámetros de consulta: debt_id (UUID), status (pending|kept|broken|cancelled), limit (por defecto 100, máximo 1000). Las más recientes primero.
json
{ "promises": [ { "id": "9a8b7c6d-...", "status": "pending", "amount_cents": 25000, "currency": "USD", "promised_date": "2026-06-21", "...": "..." } ] }PATCH /v1/promises/{id}
json
{ "status": "kept" }Máquina de estados: pending → kept | broken | cancelled, todos terminales. Volver a transicionar una promesa que ya llegó a un estado terminal → 409 conflict; cualquier otra cadena → 400 bad_request. Las filas nunca se borran. Devuelve 200 {"promise": {...}}.
Rellamadas (acordadas con el consumidor)
POST /v1/callbacks
json
{
"debt_id": "d0e1f2a3-...",
"contact_phone": "+13125551002",
"scheduled_at": "2026-06-12T16:30:00Z",
"campaign_id": "c1a2b3c4-...",
"agent_id": "f0e1d2c3-...",
"note": "consumer asked to be called back after lunch",
"priority": 1
}201 {"callback": {...}}. Obligatorios: debt_id, contact_phone, scheduled_at. Reglas:
contact_phonedebe ser ya un contacto de la deuda: las compuertas de cumplimiento necesitan sus datos de línea y de localidad →422 unprocessableen caso contrario.scheduled_atdebe estar al menos 10 minutos en el futuro →422en caso contrario; si no es RFC 3339 →400.- Deuda desconocida →
404.campaign_idtoma por defecto la campaña de la deuda.
GET /v1/callbacks
Parámetros de consulta: due=now|all, campaign_id, debt_id, status (pending|done|cancelled|missed, por defecto pending), limit.
due=now— la alimentación de vencidas para el runner: pendientes Yscheduled_at <= now, ordenadas por prioridad y luego por hora. El runner de la campaña marca las rellamadas vencidas antes que los leads nuevos y las completa él mismo cuando la fila del intento es durable.- En cualquier otro caso — la vista de cola filtrada por
status, la más próxima primero.
POST /v1/callbacks/{id}/complete|cancel|miss
Máquina de estados: pending → done | cancelled | missed (terminal). Volver a transicionar → 409 conflict. Devuelve 200 {"callback": {...}}.
Agentes (asientos)
Estados de presencia del agente: offline | available | ringing | on_call | wrap_up | paused. La presencia la gobiernan el checkin/checkout y el propio marcador: no hay un endpoint que haga PATCH de la presencia. wrap_up (trabajo posterior a la llamada, ACW) lo inicia el marcador al colgar una llamada contestada, solo cuando el ajuste gobernado agent_wrap_up_seconds es mayor que 0; su valor por defecto es 0, así que el ACW está apagado por defecto. Ver la hoja de ruta.
GET /v1/agents
Plantilla durable fusionada con la presencia en vivo:
json
{
"agents": [
{
"id": "f0e1d2c3-...",
"tenant_id": "0a1b2c3d-...",
"name": "Local Dev Agent",
"email": "[email protected]",
"role": "agent",
"status": "active",
"sip_extension": "1000",
"presence": "available",
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z"
}
]
}status es la bandera de activo/inactivo de la plantilla; presence es el estado en vivo del asiento.
POST /v1/agents
json
{ "name": "Maria Lopez", "email": "[email protected]", "role": "agent", "sip_extension": "1001" }201 {"agent": {...}}. name y email son obligatorios; role ∈ agent|supervisor|admin; sip_extension es la identidad de registro SIP a la que se puentean las llamadas.
GET /v1/agents/{id}
Un agente + presencia en vivo, o 404.
POST /v1/agents/checkin / POST /v1/agents/checkout
json
{ "sip_extension": "1000" }- checkin — el asiento pasa a
available.404si la extensión es desconocida o está inactiva;409 conflictsi el asiento está en una llamada. Eldevice_modeopcional (browser | external) persiste cómo se puentea este asiento: a los asientosbrowserse les busca primero en el softphone WSS, y losexternal(Zoiper / teléfono de escritorio) van directos al registrador; si se omite se conserva el valor almacenado, y cualquier otro →422. - checkout — solo un asiento
availablepuede salir (409en caso contrario; fail-closed: no puedes sacar un asiento de debajo de una llamada en vivo).
Los dos devuelven 200 {"agent": {...}} (el JSON del agente lleva device_mode). Las transiciones de presencia también se emiten como eventos WebSocket agent.presence.
PATCH /v1/agents/{id}
Corrige un asiento y es el verbo que lo da de baja. Solo administrador (una llave dd_ o un login con account_admin; cualquier otro login es 403 role_forbidden). Envía solo lo que cambia: name, email, role (agent|supervisor|admin), sip_extension y status (active|inactive); una clave desconocida es 400 y la nombra.
status: "inactive"da de baja el asiento: deja de contar para el tope del plan y para la factura, se revocan susddw_vivos, se le hace checkout y se desaloja su credencial SIP (sip_teardown:ok | skipped | failed). La fila nunca se borra.status: "active"sobre un asiento inactivo toma el mismo tope que la creación:422 seat_limit_reachedo422 unpriceable_tier.409 last_admin_seatsi es el único asientoadminactivo y la edición lo degradaría o lo daría de baja;409 seat_linked_to_usersi está ligado a un usuario de la consola y la edición cambia su correo;409 conflictsi el correo o la extensión ya están tomados.
POST /v1/agents/{id}/pause / POST /v1/agents/{id}/resume
Saca un asiento de la rotación (paused) y lo devuelve (available). Solo con alcance de tenant: una clave de API dd_ o un inicio de sesión del dashboard; un token de agente (widget) recibe 403.
json
{ "reason": "break" }- pause —
availableowrap_up→paused.reasones obligatorio y debe ser uno demanual | break | lunch | training | meeting; cualquier otro (o unreasonausente) →400 bad_request. - pause con
on_release: true—{ "reason": "lunch", "on_release": true }sobre un asiento enringingoon_callarma la pausa en vez de rechazar con409: el asiento sigue en su llamada, la respuesta traepause_armed: truey, cuando la llamada se libera, el asiento pasa directo apausedcon ese motivo, nunca poravailable. Armar de nuevo es idempotente (gana el último motivo) yresumela desarma. Sobre un asiento sin llamada se comporta como la pausa normal. La lectura del asiento traepause_reason(por qué está en pausa;nullsi no lo está),state_since(desde cuándo está en su presencia actual) ypause_armed. La consola del agente del dashboard ofrece su botón Pausar al terminar la llamada solo si la lectura del asiento traepause_armed. - resume —
paused→available. Sin cuerpo.
Los dos devuelven 200 {"agent": {...}} con la presencia leída de vuelta del asiento ("presence": "paused" tras una pausa). 404 si el agente es desconocido; 409 conflict si el asiento no ha hecho checkin o, para pause, está reservado, en una llamada (salvo con on_release: true) o ya en pausa, o, para resume, no está en pausa.
POST /v1/agents/{id}/wrap-up
Cierra el trabajo posterior a la llamada del agente: wrap_up → available. Deliberadamente no existe un verbo que entre en wrap_up; eso lo hace el propio marcador. 200 {"agent": {...}}; 404 si el agente es desconocido; 409 conflict si el asiento no está en trabajo posterior a la llamada (o no ha hecho checkin). El barrido periódico (el tope de agent_wrap_up_seconds) y POST /v1/agents/{id}/pause son las otras salidas de wrap_up.
Supervisión (monitoreo de llamadas en vivo)
POST /v1/supervision/calls/{call_id}/{listen|whisper|barge|takeover} — cuerpo {"supervisor_ext": "2000"}. {call_id} es el uuid de FreeSWITCH de la llamada en vivo (= call_attempts.call_uuid, también el call_id de los eventos call.* del piso).
- listen — oye ambos lados, sin que lo oiga nadie.
- whisper — audible solo para la pierna del AGENTE (coaching).
- barge — tres vías (ambos lados oyen al supervisor).
- takeover — transfiere la pierna del deudor al supervisor y suelta al agente.
listen|whisper|barge hacen sonar el endpoint REGISTRADO del supervisor (su softphone con checkin o su dispositivo SIP: contesta en 20 s para unirte). Un solo modo activo por supervisor y llamada: cambiar de modo es una llamada nueva (la pierna anterior cuelga, su fila de auditoría pasa a ended y se dispara un evento supervision.ended).
json
200 {"supervision": {"action": "listen", "call_id": "...",
"supervisor_ext": "2000", "session_id": "<leg uuid>",
"audit_id": "<row>", "to_number": "***0184"}}session_id es null en takeover; to_number va enmascarado con los cuatro últimos (el E.164 completo nunca cruza esta superficie). Evidencia primero: cada intento —con éxito O con fallo, incluidas las sondas a uuids desconocidos— escribe una fila durable en supervision_actions ANTES de cualquier orden al switch; en caso de éxito se emite un evento supervision.started. Errores: 400 si falta supervisor_ext; 404 si la extensión del supervisor es desconocida o no existe esa llamada en este tenant (los uuids de otro tenant son indistinguibles de los inexistentes y disparan CERO órdenes); 503 audit_unavailable (no se pudo escribir la fila de evidencia, así que no se envió nada); 503 esl_unavailable (enlace con el switch caído, reintenta); 502 supervision_failed (la llamada ya terminó / el dispositivo del supervisor no está registrado).
Llamadas transferidas: tramos y tiempo de gestión
En core
main(core#2403, ADR #297 de core), posterior al tagv0.129.0: aún no está en un release con tag, así que la Referencia de la API vendorizada no lista/v1/agents/handle-timenitransfer_chain. Fuente:openapi/dialer-v1.yamlychangelog.d/core-2403.mden coremain.
Una llamada transferida A -> B -> C se reconstruye tramo a tramo desde el rastro append-only call_control_actions y el reloj del CDR. No se guarda nada aparte: no hay columna nueva que corregir.
transfer_chainenGET /v1/calls/{call_id}. La llamada traetransfer_chain, los tramos en orden,[]si nadie la transfirió. Una cadena que no se puede leer es503, nunca[]. Cada tramo tieneseq(desde 1),role,agent_id,destination,via,started_at,ended_atyduration_ms.roleescontrol(el agente tiene la llamada) oconsult(el agente está en la pierna de consulta de una transferencia asistida).agent_idesnullcuando el control salió de la población de agentes (transferencia externa o toma de la llamada), o en una consulta registrada antes de que se estampara el agente.viaesoriginpara el agente con quien empezó la llamada; si no, el verbo que abrió el tramo (blind_transfer,attended_transfer_complete,attended_transfer_start, ...).destinationes la extensión o el número al que apuntó ese verbo, vacío en el tramo de origen.started_at,ended_atyduration_mssonnullmientras el tramo sigue abierto o nunca se contestó: no se inventan.
GET /v1/agents/handle-time. Recibefrom(inclusivo) yto(exclusivo) en RFC 3339, y unagent_idopcional; la ventana cuenta las llamadas que empezaron en ella, abarca como máximo 31 días (400si no) y 10 000 llamadas (422: se rechaza, nunca se trunca). Solo credenciales de gestión (dd_oddu_); un token de agenteddw_recibe403. Por agente respondecalls,control_ms(tiempo con la llamada en mano),consult_ms(tiempo en una pierna de consulta),occupied_ms(su suma) yaht_ms(control_ms / calls,nullsi el agente no tuvo ninguna). A cada agente se le acreditan solo sus tramos: a A hasta el traspaso, a B de ahí al siguiente, a C hasta el final; no la llamada entera al agente que nombra la fila del CDR. Un tramo aún no medible (la llamada está viva) se deja fuera y se cuenta enexcluded.open_legs; el control que pasó a un número externo no acredita a ningún agente.definitiones"v1"y nunca cambia de significado.503si no se puede leer la evidencia.GET /v1/agents/occupancyno cambia. El asiento de origen sigueon_callhasta que termina la llamada, aunque ya la haya transferido; para saber quién la tuvo y cuánto, usahandle-time.
El detalle de llamadas del constructor de reportes (CSV incluido) lleva la misma cadena en dos columnas: ver Constructor de reportes.
Llamadas entrantes en un troncal BYOC de toda la empresa
Publicado en core
v0.88.0(core#1565, ADR #203 de core). La ruta es la mismaPOST /v1/carriers, cubierta por la Referencia de la API generada; aquí se describenscope: "empresa"ysource_ips.
Una cuenta puede usar un troncal byoc para las llamadas entrantes a DIDs que pertenecen a cualquiera de sus oficinas. Regístralo con scope: "empresa" y las direcciones IPv4 o IPv6 desnudas desde las que ese par SIP envía tráfico:
json
POST /v1/carriers
{
"kind": "byoc",
"name": "company-primary",
"sip_proxy": "sip:pstn.example.test:5060",
"scope": "empresa",
"source_ips": ["203.0.113.9"]
}Crear un troncal de toda la empresa exige una sesión de dashboard account_admin; un miembro o una clave de API de máquina recibe 403. Un troncal de sede (POST /v1/carriers sin scope: "empresa") y su PATCH, provision y revoke exigen una clave dd_ o un inicio de sesión account_admin: un inicio de sesión member recibe 403 role_forbidden (core#2647; ver Quién puede escribir). La cuenta se toma siempre de esa sesión: account_id en el cuerpo se ignora y tenant_id se rechaza. Cada dirección de origen puede pertenecer a un solo troncal no retirado (activo o deshabilitado) en toda la plataforma; una dirección ya asignada a cualquier troncal de oficina o de toda la empresa es 400 invalid con el mensaje source_ips already assigned to another trunk. Una lista source_ips vacía es válida, pero ese troncal solo lleva entonces tráfico saliente.
Lo que POST /v1/carriers rechaza (core#2424, core#2345, core#2354):
kind: "house"es422. Desde esta ruta solo se aceptakind: "byoc": un troncalhousees del administrador de la plataforma, que lo registra por/v1/admin/carriers. El mensaje nombra el campo,kind.- Una dirección de uso especial en
source_ipses422. Se rechazan los rangos de loopback, privados (RFC 1918), CGNAT, link-local, de documentación, multicast y reservados, y sus equivalentes IPv6; el mensaje nombra el campo,source_ips. Usa las direcciones públicas desde las que envía tu operador. - Un
sip_proxyque no es un destino públicosip:/sips:es422. Incluye un valor mal formado, una dirección de loopback, privada, link-local, CGNAT, multicast o no especificada, un nombre interno de la celda (localhost, una sola etiqueta,.local,.internal,.svc) y un rango que la celda declara como propio. - Un
sip_proxyya usado por otra cuenta es409con códigotrunk_endpoint_shared. Su host y puerto ya son el destino de un operador de la plataforma o de un troncal BYOC activo de otra cuenta; dos cuentas no pueden compartir un destino, dos oficinas de la misma cuenta sí.POST /v1/carriers/{id}/provisionaplica la misma comprobación (422o409) y no empuja nada.
Las dos primeras comprobaciones (kind y source_ips) van tras la bandera carrier_source_ip_class_check, activa por defecto; los troncales ya guardados no cambian.
Cuando la respuesta es un error no se escribe nada.
Tras el registro, POST /v1/carriers/{id}/provision escribe el troncal en el borde SIP. source_ips, sip_proxy y scope son valores aprovisionados y no se pueden cambiar con PATCH /v1/carriers/{id}; esa ruta solo cambia allowed_destinations. POST /v1/carriers/{id}/revoke quita la ACL entrante, retira el troncal de forma permanente y libera sus direcciones de origen para otro troncal. Revocar un troncal de toda la empresa, como crearlo, exige account_admin.
La IP de origen demuestra a qué cuenta pertenece el par; no elige una oficina. El número E.164 marcado debe tener una ruta DID entrante activa, y esa ruta selecciona la oficina que recibe la llamada. Por tanto:
- un DID de cualquier oficina de la misma cuenta puede recibir la llamada;
- un DID de otra cuenta, un DID desconocido o una IP de origen sin clasificar se rechaza en el borde SIP;
- un número de nivel empresa que haga sonar varias oficinas, restringir el troncal a un subconjunto de oficinas, rangos CIDR y varias cuentas detrás de una misma IP de origen no forman parte de esta superficie.
El valor interno account=<company> en la ruta DID y la clasificación de origen acct:<company> son detalles de implementación del enrutamiento; los clientes de la API configuran el comportamiento admitido solo mediante scope, source_ips y las rutas DID de la cuenta.
Troncales BYOC: lista de destinos permitidos
Un troncal byoc puede declarar allowed_destinations: una lista de números E.164 exactos a los que la cuenta puede llamar a través de él. Existe para cuentas que nunca deben alcanzar a nadie más: una cuenta de QA, o la prueba de un prospecto que solo puede llamar a los números que el prospecto confirmó como propios. Sin lista, nada cambia.
PATCH /v1/carriers/{id}
{ "allowed_destinations": ["+13125550100", "+13125550101"] }- Declararla.
POST /v1/carriersacepta el campo al crear,GETlo devuelve, yPATCH /v1/carriers/{id}reemplaza la lista entera:[]onullla levanta.allowed_destinationses la única clave que aceptaPATCH. Hasta 100 números, E.164 estricto (+y dígitos), sin duplicados; la lista se guarda cifrada. Solo un troncalbyocadmite una. - Quién puede cambiarla. Un troncal de toda la empresa obliga a todas las oficinas que lo heredan, así que cambiar su lista exige un usuario
account_admin(403en caso contrario), igual que crearlo. Un troncal retirado es409; el troncal de otra empresa es404. - A qué obliga. A toda llamada que la plataforma hace por esa oficina hacia la red telefónica: marcados de campaña (reintentos y rellamadas vencidas incluidos), marcados manuales y de vista previa, y la pierna saliente de una transferencia a ciegas a un número externo. Cuando varios troncales BYOC activos de la oficina declaran una lista —el suyo y el de la empresa— el número debe estar en cada una. La regla obliga a la oficina, no a un troncal: una campaña enrutada por otro operador telefónico queda restringida igualmente.
- Falla en modo cerrado. Si la lista no se puede leer, la llamada se rechaza como si el número estuviera fuera de ella.
Lo que ves cuando un destino queda fuera de la lista: no se marca nada, y el rechazo se archiva como un bloqueo tenant_policy:
| Dónde | Qué recibes |
|---|---|
| Marcado de campaña | El intento se bloquea y se cuenta bajo tenant_policy en GET /v1/stats/blocked. |
Marcado manual/de vista previa (POST /v1/campaigns/{id}/dial) | 200 {"status": "blocked", "reason": "tenant_policy"}. |
Transferencia a ciegas a un número externo (POST /v1/calls/{call_id}/transfer) | 422 compliance_blocked con gate: "pre_dial", reason_code: "destination_not_allowed" (o "destination_allowlist_unavailable"), el rule_id y, si se escribió la fila de evidencia, el decision_id. El consumidor sigue en la llamada. |
La regla es egress.destination_not_allowed.v1, o egress.destination_allowlist_unavailable.v1 cuando la lista no se pudo leer; es visible en la decisión de la cadena de auditoría (GET /v1/compliance/decisions/{id}).
Dos límites que conviene conocer:
- La comprobación previa de cumplimiento y la vista previa del rewind no evalúan la lista, así que pueden contar como marcable un lead que el propio marcado rechaza después.
- La lista protege contra marcar por error, no contra tu propio administrador: quien puede gestionar el troncal puede levantarla.
Troncales BYOC: SRTP
Publicado en core
v0.127.0(core#2333). Referencia: el camposrtpdePOST /v1/carriers.
Un troncal byoc puede declarar srtp, la política de cifrado del medio en su pierna saliente hacia tu proveedor. La consola todavía no muestra el campo: se fija por la API, al crear el troncal.
srtp | Qué hace |
|---|---|
off (por defecto) | RTP en claro, exactamente como antes de que existiera el campo. Omitir srtp equivale a off. |
offer | La plataforma ofrece SRTP y acepta RTP en claro si tu proveedor contesta sin él. |
require | La plataforma rechaza una respuesta que no sea SRTP. |
http
POST /v1/carriers
{
"kind": "byoc",
"name": "Provider with TLS",
"sip_proxy": "sips:pstn.example.test:5061",
"srtp": "require"
}- La condición de TLS.
offeryrequireexigen unsip_proxyTLS:sips:o;transport=tls. Si no, la petición es400 invalidcon el mensajesrtp offer and require need a TLS sip_proxy. - No escribas
srtp=en el proxy. La plataforma compone ese parámetro ella misma; unsip_proxyque ya lo lleva es400 invalidcon el mensajesip_proxy must not carry an srtp parameter; use the srtp field. Un valor fuera deoff,offeryrequiretambién es400 invalid. - Cambiarlo.
srtpes un valor aprovisionado, comosip_proxy:PATCH /v1/carriers/{id}no lo acepta. Para cambiarlo, revoca el troncal y da de alta otro. - En el borde.
POST /v1/carriers/{id}/provisionescribe el proxy como<sip_proxy>;srtp=<modo>solo paraofferyrequire; conoffescribe el mismo valor de antes. - Líneas de la plataforma. Los troncales house de la plataforma llevan el mismo campo, fijado por el operador; no es un ajuste del cliente.
Troncales BYOC: techo de capacidad y retenciones de dinero
core#2430 (BYOC sin techo propio), core#2425 (IA de voz por un troncal BYOC) y core#2427 (llamada entrante contestada), todos en
mainde core. Nada de esto añade un endpoint ni un campo: cambia qué marcaciones y llamadas se admiten y qué retienen.
Una marcación por el troncal byoc propio de la cuenta gasta el operador telefónico de la cuenta, no el de la plataforma, pero sigue usando el pool de medios compartido. De ahí salen tres reglas, con el código de core que respalda cada cifra:
| Cifra | Dónde rige | Respaldada por (core main) |
|---|---|---|
| 100 canales simultáneos | Una marcación BYOC de una cuenta cuya fila no declara max_concurrent_channels | byoc_assumed_channels, @default_byoc_assumed_channels en lib/dialer/dialer/capacity.ex |
| 20 llamadas por segundo | Una marcación BYOC de una cuenta cuya fila no declara max_cps | byoc_assumed_cps, @default_byoc_assumed_cps en lib/dialer/dialer/capacity.ex |
| 20 llamadas simultáneas | Una marcación de IA de voz por BYOC de una cuenta de suscripción o pospago sin techo propio (en lugar del 100 / 20 de arriba) | @default_assumed_channels y @default_postpaid_assumed_channels en lib/dialer/billing/spend_guard.ex |
| 3 600 segundos | El corte por defecto de una llamada entrante contestada que no atiende la IA | inbound_max_call_seconds (INBOUND_MAX_CALL_SECONDS) en lib/dialer/inbound/call_handler.ex |
- Qué techo rige una marcación BYOC. Campo a campo, el valor propio de la cuenta manda siempre, en los dos sentidos: con
max_concurrent_channels: 500la marcación admite 500. Solo un campo ausente toma el supuesto. El techo de una cuenta lo escribe Dialer Digital con el token de plataforma (PATCH /v1/admin/tenants/{id}); la cuenta no puede escribirlo. Las marcaciones BYOC cuentan en su propio carril mientras el techo es el supuesto, así que nunca ocupan las plazas de las marcaciones de línea de la plataforma de la cuenta, ni al revés.GET /v1/stats/capacityno cambia: sigue describiendo el techo de una marcación de línea de la plataforma. - Qué ve el cliente cuando se niega. La marcación no se hace y el motivo es
tenant_channels(el canal simultáneo 101) otenant_cps(la 21 en el mismo segundo). Son los mismos motivos de la tabla del freno de toda la cuenta: la campaña siguerunningy vuelve a marcar cuando hay hueco. - IA de voz por BYOC. Las marcaciones con
ai_usedpor un troncalbyocpasan ahora por el freno de dinero, solo en el carril de la IA: los minutos del operador telefónico no llevan tarifa ni bolsa de minutos. El prepago retiene la tarifa de IA por la ventana supuesta (saldo cero:wallet_insufficient, no se marca); la suscripción cuenta solo la bolsa de minutos de IA y, pasada ella, el exceso se paga del saldo; el pospago conserva su techo de crédito con el precio de la IA como único coste (plan_exhausted). Una marcación BYOC sin IA no cambia: sin retención y solo con el techo de 100 / 20. El prepago no tiene techo supuesto: su retención es su tope. - Entrante contestada. Antes de contestar, la llamada toma un canal de la cuenta (se rechaza con
tenant_channelsocapacity_unavailablesi no hay) y pasa por el freno de dinero: una cuenta de prepago, o de suscripción con la bolsa agotada, toma una retención de saldo que se libera al terminar la llamada; una de pospago, o de suscripción dentro de su bolsa, no retiene saldo pero recibe un techo de duración. Sin margen, la llamada se rechaza (wallet_insufficienten prepago,plan_exhausteden suscripción o pospago). Una entrante rechazada se registra con disposiciónrefused_<motivo>y causa de colgadoCALL_REJECTED. Toda ruta que la IA no atiende (agente, menú, cola) se corta a los 3 600 segundos, o antes si el techo de la propia retención es menor; la cifra es un ajuste de la celda, no de la cuenta. El mismo cambio consulta el presupuesto mensual de IA de voz (ai_spend_cap) antes de que la IA admita una entrante, y la llamada cae a su cola cuando se agotó; como ninguna IA contesta llamadas entrantes hoy, esa comprobación aún no se observa.
Salud del troncal BYOC
Publicado en core
v0.87.0(core#1522, ADR #196), y en la Referencia de la API.
Dos endpoints de solo lectura exponen el flujo de salud de un troncal byoc:
GET /v1/carriers/{id}/healthdevuelve el último estado de salud, concarrier_id,state,reason,since,observed_at,freshy las últimassignalsde sonda/CDR.GET /v1/carriers/{id}/health/eventsdevuelve el historial de transiciones, la más reciente primero, con paginación keyset (limitycursor). Por defecto excluye las filas de confirmación de cinco minutos; añadeinclude_confirm=truepara incluirlas.
El estado actual es up, down o unknown. unknown es el estado esperado antes de la primera medición; también representa una medición ilegible o caducada. No es un up optimista. Los dos endpoints devuelven 404 para un troncal fuera del alcance de quien llama, un id desconocido o mal formado, o un troncal propio de la plataforma; la respuesta de salud no expone el proxy SIP del troncal.
La recogida de salud la controla la bandera operativa de toda la plataforma byoc_trunk_health_probe_enabled. Está apagada por defecto, y solo un operador puede encenderla. Apagada, no se reconcilia ningún conjunto de sondas y el evaluador de salud no escribe eventos de salud, así que un troncal no medido antes sigue leyéndose como unknown. Esta señal es informativa: no cambia el enrutamiento de llamadas ni relaja una compuerta de cumplimiento.
Horario de llamadas del tenant
Publicado en core
v0.87.0(core#1527, ADR #197), y en la Referencia de la API.
Una cuenta puede declarar su propio horario semanal de llamadas —«nunca antes de las 9, nunca después de las 20»— una vez, en lugar de repetirlo en cada campaña. Es una tercera ventana sobre cada campaña y solo estrecha: una llamada sale solo cuando las tres están abiertas: el horario legal en el destino (horas de silencio), la ventana de la campaña y la de la cuenta. El horario de la cuenta nunca puede abrir una llamada que las otras dos cerraron. Sin él, nada cambia.
PUT /v1/tenant/calling-window
{ "timezone": "America/Chicago",
"weekly": { "mon": { "start": "09:00", "end": "20:00" },
"tue": { "start": "09:00", "end": "20:00" } } }- Forma.
timezonees una zona IANA y es obligatoria: las horas se leen en ese reloj, no en el del destino (el reloj del destino es la ventana legal, que sigue en vigor).weeklyasignamon…suna{start, end}; un día sin entrada está cerrado,startdebe ser anterior aendy una ventana no cruza la medianoche. Exactamente esas dos claves; cualquier otra se rechaza. - Leer y quitar.
GET /v1/tenant/calling-windowresponde{"calling_window": null}cuando la cuenta no tiene ninguno, o la ventana guardada con sus horas normalizadas aHH:MM:SS.DELETEla quita y responde{"calling_window": null}. Un valor guardado que no se puede leer es503, nunca «sin ventana». - Quién puede escribir. Una clave de API
dd_, o un usuario del dashboard con el rolaccount_admin; cualquier otro inicio de sesión del dashboard puede leer pero recibe403 role_forbiddenenPUT/DELETE. Cada escritura se audita con su autor. - Falla en modo cerrado. Un cuerpo mal formado es
422 unprocessablecon el motivo y no se guarda nada. Si un valor guardado no se puede evaluar cuando una llamada está a punto de salir, la llamada no sale.
Lo que ves fuera del horario de la cuenta: no se marca nada, y el rechazo se archiva como un bloqueo tenant_policy:
| Dónde | Qué recibes |
|---|---|
| Marcado de campaña | El intento se bloquea, se archiva con su rule_id y se cuenta bajo tenant_policy en GET /v1/stats/blocked. El lead se vuelve a ofrecer tras el enfriamiento del bloqueo (60 segundos por defecto). |
Marcado manual/de vista previa (POST /v1/campaigns/{id}/dial) | 200 {"status": "blocked", "reason": "tenant_policy"}. |
La regla es tenant.calling_window_closed.v1 fuera del horario, o tenant.calling_window_unavailable.v1 cuando un valor guardado no se pudo evaluar.
Dos límites que conviene conocer:
- La pierna saliente de una transferencia a ciegas a un número externo no se comprueba contra el horario de la cuenta; sigue bajo el horario legal y el registro de destinos de transferencia.
- Mientras la cuenta está fuera de su horario, cada lead que la campaña ofrece escribe una fila de bloqueo aproximadamente una vez por minuto (el enfriamiento), así que una campaña que se deja en marcha durante la noche deja un rastro largo de evidencia.
Llamadas entrantes: rutas, menús IVR y colas
Tres superficies deciden qué pasa con una llamada a uno de los números de la cuenta: la ruta del número (/v1/inbound-routes), los menús IVR entrantes que una ruta puede ejecutar (/v1/inbound-ivr-menus) y las colas entrantes a las que una ruta o un menú puede enviar a quien llama (/v1/inbound-queues). Los schemas completos de petición y respuesta están en la Referencia de la API (rutas, menús, colas); esta sección es con lo que se topa un integrador. El recorrido del dashboard es el módulo 10 de la Academia.
Constrúyelas en este orden: primero las colas, luego el menú que las nombra, y luego la ruta que nombra el menú o la cola.
Rutas: /v1/inbound-routes
La comprobación del número (core#2414, core#2429) está en core
main, posterior al tagv0.129.0: un despliegue con una versión anterior todavía guarda la ruta de un número que no es de la cuenta.
POST /v1/inbound-routes toma e164 (inmutable después) y dest_type, uno de cuatro. El número ha de ser un DID activo de la cuenta: da de alta el número en /v1/dids antes de crear la ruta.
dest_type | Qué nombra | Qué recibe una llamada |
|---|---|---|
agent | dest_ref = el id del agente (obligatorio). | Hace sonar el softphone del agente. Con recording: true —el valor por defecto— la llamada llega al agente y se graba desde el momento en que contesta; si no se encuentra al agente, la llamada se cuelga de inmediato. |
ivr | dest_ref = el id de un menú publicado, no retirado, de esta cuenta (obligatorio). | Ejecuta ese menú (ver abajo). |
queue | queue_id = una cola activa de esta cuenta (opcional, ver abajo). | Espera en esa cola, solo mientras las colas estén activadas para la cuenta (ver capability). |
optout | Nada. La ruta va desnuda. | El número que llama pasa a la lista de no llamar propia de la cuenta. |
El número de exclusión (optout). El número que llama se añade a la lista de no llamar propia de la cuenta cuando la llamada conecta, antes de que conteste nada y sea cual sea el estado del plan. Con un plan activo, quien llama oye entonces una confirmación que la plataforma compone a partir del nombre de vendedor de la cuenta —«Your number has been added to the do-not-call list of <seller>. Goodbye.»— o un tono cuando no hay nombre de vendedor, y la llamada cuelga. Con un plan inactivo la llamada se rechaza sin contestar, y la exclusión ya está escrita. Quien llama con un número oculto o inválido es rechazado y no se escribe nada; una exclusión que no se pudo escribir nunca se confirma. La ruta va desnuda: un dest_ref no nulo, recording: true o ai_enabled: true en la misma escritura es 400 invalid, y lo que la escritura deja fuera queda en ninguno / false.
POST /v1/inbound-routes
{ "e164": "+18005550142", "dest_type": "optout" }El menú (ivr). Nombra el id del menú —el id que se le dio en la versión 1, que no cambia entre versiones— de modo que editar el menú nunca obliga a reapuntar rutas: cada llamada oye la versión vigente cuando conecta. Una ruta que no nombra un menú publicado de esta cuenta, o nombra uno retirado, es 400 invalid, y también lo es reactivar una ruta cuyo menú se retiró entretanto.
La cola (queue_id). Solo en una ruta queue. Una cola activa de esta cuenta; una retirada, de otra cuenta, inexistente o un id mal formado es 400 invalid, y también lo es queue_id en cualquier otro dest_type. El id se compara sin distinguir mayúsculas y se lee de vuelta en minúsculas. Es opcional —una ruta queue sin él se acepta igualmente—, pero una ruta así rechaza todas las llamadas al conectar, así que trátalo como obligatorio. Un PATCH que mueve una ruta a otro dest_type sin nombrar queue_id lo descarta.
Errores en las escrituras de ruta:
| Lo que envías | Lo que recibes |
|---|---|
Un e164 que no es un DID activo de esta cuenta (de otra cuenta, o que nadie dio de alta), en el POST, o en un PATCH que devuelve una ruta a active | 422 con el error de campo e164: «not a number of this account». No se guarda nada. Una ruta que ya existe nunca se vuelve a juzgar. |
Un dest_ref que no es un UUID canónico, con cualquier dest_type | 400 invalid con el error de campo dest_ref must be a UUID. No se guarda nada. |
ivr sin un menú publicado, no retirado, de esta cuenta | 400 invalid en dest_ref. |
queue_id que no es una cola activa de esta cuenta, o en un tipo que no es queue | 400 invalid. |
optout con un dest_ref, recording: true o ai_enabled: true | 400 invalid. |
ai_enabled: true en cualquier ruta | 400 invalid: la IA entrante todavía no está disponible, así que ninguna IA contesta una llamada entrante. |
Después, POST /v1/inbound-routes/{id}/provision empuja la ruta al borde SIP, y POST /v1/inbound-routes/{id}/disable la apaga.
Quién puede escribir. POST y PATCH /v1/inbound-routes, …/{id}/provision y …/{id}/disable exigen una clave dd_ o un inicio de sesión account_admin del dashboard; un inicio de sesión member recibe 403 role_forbidden y no se guarda nada. La lectura (GET) no cambia. Ver Quién puede escribir la telefonía.
Menús IVR entrantes: /v1/inbound-ivr-menus
Un menú es un árbol de nodos. Cada nodo tiene:
prompt— lo que oye quien llama al entrar en el nodo, con tus palabras: como máximo 2000 bytes, nunca en blanco. La plataforma no añade texto propio.default— obligatorio: la acción que se ejecuta cuando no se pulsa ninguna tecla. Siempre una acción terminal y nuncaoptout: no pulsar nada no es pedir salir de la lista.entries— al menos una: una tecla DTMF (0–9,*,#) →{"action": …}o{"submenu": {…node…}}.
Las acciones terminales son queue:<queue_id>, agent:<agent_id>, hangup y optout. Como máximo 5 niveles y 64 nodos.
POST /v1/inbound-ivr-menus
{
"name": "Main line",
"menu": {
"prompt": "For payments, press 1. To speak to an agent, press 2. To stop these calls, press 9.",
"default": "queue:6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21",
"entries": {
"1": { "action": "queue:6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21" },
"2": { "action": "agent:0b8d2f4e-5a6c-4d1e-8f3b-2c7a9e1d4b60" },
"9": { "action": "optout" }
}
}
}Publicar. Sin menu_id, la petición publica un menú nuevo en la versión 1, cuyo id es desde entonces el del menú; con menu_id publica la versión siguiente de ese menú, que pasa a ser la vigente. Nada se edita en el sitio: GET /v1/inbound-ivr-menus/{id} devuelve la versión vigente y un history con todas las versiones. En orden, y no se escribe nada a menos que todo pase: se valida el árbol; cada queue:<id> debe ser una cola activa de esta cuenta y cada agent:<id> un agente activo de esta cuenta; y cada mensaje se convierte en audio ahora, no cuando llega una llamada.
En una llamada. Tras la comprobación del plan se contesta la llamada, suena el mensaje y core espera una tecla. Una tecla que el nodo no lista no hace nada. Sin tecla, el mensaje suena una vez más y, tras la segunda ventana en silencio, se ejecuta el default del nodo. La ventana se cuenta desde que se envía el mensaje: 5 segundos más una estimación de la duración del mensaje (60 ms por byte, con tope de 30 s). Un destino inalcanzable —colas apagadas para la cuenta, un agente sin asiento, una transferencia que el switch rechaza— cuelga la llamada de inmediato en lugar de dejar a quien llama en silencio. En una ruta ivr con recording: true, la grabación empieza cuando contesta un agente; el menú en sí nunca se graba. El registro de la llamada conserva el menú, la versión que oyó, las teclas pulsadas y adónde fue.
Errores:
| Operación | Lo que recibes |
|---|---|
Publicar un árbol que rompe las reglas de arriba (sin default, default: optout, un mensaje en blanco o demasiado largo, una tecla errónea, demasiada profundidad, demasiados nodos, un campo desconocido) | 400 |
Publicar un queue:<id> o agent:<id> que no es una cola o un agente activo de esta cuenta —el de otra cuenta, uno inexistente y una cola retirada se leen igual— | 422 unprocessable, nombrándolos |
| Publicar un mensaje que no se convierte en audio | 503 prompt_render_failed. No se publicó nada; la misma petición puede reintentarse. |
Publicar con un menu_id que no es un menú de esta cuenta | 404 |
Publicar con el menu_id de un menú retirado | 409 conflict: un menú retirado sigue retirado; publica uno nuevo. |
POST /v1/inbound-ivr-menus/{id}/retire mientras una ruta activa aún usa el menú | 409 conflict, nombrando las rutas: deshabilítalas o apúntalas antes a otro menú. |
| Retirar un menú que no es un menú vigente de esta cuenta (desconocido, ya retirado, de otra cuenta) | 404 |
Publicar o retirar con un inicio de sesión del dashboard que no es account_admin | 403 role_forbidden |
La lectura (GET de la lista, ordenada por nombre, y de un menú) está abierta a una clave dd_ y a cualquier inicio de sesión del dashboard; publicar y retirar exigen una clave dd_ o un inicio de sesión account_admin. Un token de widget (ddw_) recibe 403 forbidden en todos ellos.
Colas entrantes: /v1/inbound-queues
| Operación | Qué hace |
|---|---|
POST /v1/inbound-queues {"name": "Sales"} | Crea una cola activa sin desbordamiento. El nombre se recorta, de 1 a 80 caracteres, y es único entre las colas activas de la cuenta sin distinguir mayúsculas (409 conflict en caso contrario); el nombre de una cola retirada vuelve a quedar libre. Una cola nunca se renombra. |
GET /v1/inbound-queues | Las colas activas, ordenadas por nombre —una cola creada antes de que existieran los nombres se lee name: null y va la última—, cada una con su desbordamiento y active_route_count (las rutas queue activas que la nombran), más capability. |
GET /v1/inbound-queues/{id} | Una cola, incluidas las retiradas (status: retired), más capability. |
PUT /v1/inbound-queues/{id}/overflow | Una llamada que ha esperado overflow_max_wait_ms pasa a overflow_queue_id. Ambos campos son obligatorios. |
DELETE /v1/inbound-queues/{id}/overflow | Quita el desbordamiento: una llamada espera todo lo que haga falta. |
POST /v1/inbound-queues/{id}/retire | Retira la cola para siempre: sale de la lista, no puede ser nombrada por una ruta, un menú ni un desbordamiento, y no se puede configurar; su historial se conserva. |
GET /v1/inbound-queues/live | Cuántas llamadas esperan en cada cola activa y desde hace cuánto: ver la lectura en vivo. |
GET /v1/inbound-queues/{id}/live | La misma lectura para una cola activa (404 para una cola retirada, inexistente o de otra cuenta). |
PUT /v1/inbound-queues/{id}/overflow
{ "overflow_max_wait_ms": 120000, "overflow_queue_id": "6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21" }Errores:
| Operación | Lo que recibes |
|---|---|
| Crear con un nombre ya usado por una cola activa (en cualquier caja) | 409 conflict |
| Desbordamiento con un máximo que no es un entero positivo, un destino que es esta misma cola o no es una cola activa de esta cuenta (retirada, de otra cuenta, inexistente, mal formada), o uno que ya desborda —directamente o a través de otras colas— de vuelta a esta | 400 invalid. Un ciclo de desbordamiento se rechaza. |
| Desbordar, retirar o leer una cola que no es una cola activa de esta cuenta | 404 (leer una responde también las retiradas) |
| Retirar mientras algo activo nombra la cola | 409 conflict, nombrando cada uno: the queue is still in use; re-point or disable these first — inbound routes: …; inbound IVR menus: …; queue overflows: …. Deshabilítalos o reapúntalos primero. |
Crear, desbordar o retirar con un inicio de sesión del dashboard que no es account_admin | 403 role_forbidden |
Mismos lectores y escritores que los menús: la lectura está abierta a una clave dd_ y a cualquier inicio de sesión del dashboard, la escritura exige una clave dd_ o un inicio de sesión account_admin, y un token de widget (ddw_) recibe 403 forbidden. Una vez retirada una cola, un menú que la nombra ya no se puede publicar (422) y una ruta no puede nombrarla (400).
capability: si la cola recibe llamadas
La lista y la lectura de una cola llevan el capability de la cuenta:
json
"capability": {
"flag": "inbound_queue_stream_enabled",
"enabled": false,
"switches": {
"inbound_queue_stream_enabled": false,
"agent_session_journal_enabled": false
}
}Una cola recibe llamadas solo cuando los dos interruptores están encendidos para la cuenta, y enabled es true solo entonces. Dialer Digital los enciende por cuenta; no hay ningún control en la API ni en el dashboard para que la cuenta los encienda, y esta lectura no cambia ninguno. Mientras enabled es false, una llamada a una ruta queue no se contesta y no se ofrece a ningún agente, sea cual sea la configuración de la cola, y una tecla de menú que lleva a una cola cuelga la llamada. switches dice cuál está apagado, de modo que una interfaz pueda decirlo en lugar de ofrecer una cola que no recibe llamadas. Entretanto la cuenta puede crear colas, fijar su desbordamiento y apuntar rutas y menús hacia ellas.
La lectura en vivo: profundidad y mayor espera
Publicado en core
v0.87.0(core#1526), y en la Referencia de la API.
GET /v1/inbound-queues/live responde, para cada cola activa de la cuenta, cuántas llamadas esperan y desde cuándo:
json
{
"as_of": "2026-10-04T18:00:00.000000Z",
"stream_enabled": true,
"ready_agents": 3,
"inbound_queues": [
{ "queue_id": "6f1c1b7e-2d7a-4c1e-9d3a-0b5e8f7a1c21", "name": "Sales", "depth": 2, "oldest_wait_ms": 120000, "wait_bound_ms": 3600000 }
]
}GET /v1/inbound-queues/{id}/live devuelve el mismo cuerpo con un inbound_queue en lugar de la lista.
depthes el número de llamadas que esperan ahora: las admitidas en la cola que no se han contestado, abandonado ni movido a otra cola. Se recalcula a partir del historial de eventos de solo anexado de la cola en cada lectura; no hay contador que pueda desviarse. Una llamada ofrecida a un agente que aún no la ha tomado sigue esperando y cuenta. Una llamada que desbordó a otra cola se cuenta allí, donde espera ahora.oldest_wait_mses cuánto lleva esperando la llamada que más espera, leído del reloj enas_of. Esnullcuando nadie espera. Es una lectura en vivo para un supervisor, no la espera medida que se escribe cuando una llamada sale de la cola (esa es la que usan el historial y los informes), así que no son el mismo número.- Apagado no es cero. Mientras
inbound_queue_stream_enabledestá apagado para la cuenta, nada escribe el historial de la cola, así que la respuesta esstream_enabled: falsecondepth: nullyoldest_wait_ms: null, nunca0.ready_agentssigue la misma regla conagent_session_journal_enabled:nullcuando ese interruptor está apagado. Muestra «las colas están apagadas», no una cola vacía. wait_bound_mses el límite pasado el cual no se cuenta una llamada que nunca recibió un evento final, de modo que una caída entre entrar en una cola y salir de ella no pueda inflar la profundidad para siempre. Es eloverflow_max_wait_msde la cola cuando tiene desbordamiento, o una hora cuando no lo tiene.ready_agentses el número de agentes Ready de toda la cuenta, no por cola.
La lectura está abierta a una clave dd_ y a cualquier inicio de sesión del dashboard; un token de widget (ddw_) recibe 403 forbidden. No hay un evento de profundidad de cola en el socket del piso en vivo, así que una pantalla que lo muestre tiene que consultarlo periódicamente; cada 5 segundos basta para un supervisor.
Caller ID y DIDs
El registro de los números propiedad del tenant desde los que marcan las campañas, más la salud calculada a partir del CDR.
GET /v1/dids?status=active|quarantine|retired→{"dids": [...]}(ordenado pore164; un status erróneo →400).POST /v1/dids→201 {"did": {...}}. Campos:e164(obligatorio, E.164, único por tenant →409ante duplicados),npa(derivado de los números+1si se omite),us_state,status(por defectoactive),labels(arreglo de cadenas),notes. El tenant no escribeattestation(A|B|C|unknown, el nivel STIR/SHAKEN que firma el operador telefónico): se acepta y se ignora, y sólo la plataforma lo fija (ver Titularidad del número). La respuesta lo trae de sólo lectura (schemaDIDencore:openapi/dialer-v1.yaml).GET /v1/dids/{id}→ un DID.Quién puede escribir.
POST,PATCHyDELETE /v1/didsexigen una clavedd_o un inicio de sesiónaccount_admin; un inicio de sesiónmemberrecibe403 role_forbidden. LosGETsiguen abiertos. Ver Quién puede escribir la telefonía.PATCH /v1/dids/{id}— solo metadatos/estado;e164es inmutable (un número distinto es un DID distinto; el historial del CDR nunca se reatribuye).DELETE /v1/dids/{id}— retira (statusretired), idempotente. Las filas nunca salen del registro: la procedencia delfrom_numberdel CDR debe seguir resolviéndose para el paquete de defensa.GET /v1/dids/{id}/health?from&to(RFC 3339 sobrestarted_at, por defecto los últimos 30 días UTC) → tasa de respuesta y tasa de llamadas cortas calculadas a partir del CDR de solo anexado:json{"did_id": "...", "e164": "+13125550184", "status": "active", "period": {"from": "...", "to": "..."}, "dials": 412, "answered": 67, "answer_rate": 0.1626, "short_calls": 24, "short_call_rate": 0.3582, "short_call_seconds": 15, "daily": [{"date": "2026-06-01", "dials": 80, "answered": 14, "short_calls": 5}], "reputation": {"hiya": null, "tns": null, "note": "external feeds not integrated yet"}}short_call_rate(llamadas contestadas que terminan en menos de 15 s) es la señal de «posible spam» más fuerte del lado del CDR: contestan, ven la etiqueta, cuelgan. Las tasas sonnullcuando el denominador es cero (nunca se inventan);reputation.*sonnullexplícitos hasta que se contrate una fuente externa.
Quién puede escribir la telefonía
Catorce escrituras cambian cómo te llegan y salen las llamadas (core#2647). Exigen una clave dd_ o un inicio de sesión account_admin del dashboard; un inicio de sesión member recibe 403 role_forbidden con el mismo cuerpo envíe lo que envíe, y no se guarda nada:
| Recurso | Escrituras |
|---|---|
| Números | POST, PATCH, DELETE /v1/dids |
| Rutas de entrada | POST, PATCH /v1/inbound-routes, …/{id}/provision, …/{id}/disable |
| Troncales de sede | POST, PATCH /v1/carriers, …/{id}/provision, …/{id}/revoke (un troncal de toda la empresa ya exigía solo account_admin) |
| Sitios del widget | POST /v1/widget_origins, DELETE …/{id}, POST …/{id}/enable (ver Incrustar el widget) |
El GET de cada uno no cambia. La regla va detrás del ajuste por cuenta telephony_config_admin_only, encendido por defecto y fijado por la plataforma, no por ti. Si todo usuario del dashboard debe conservar este alcance (por ejemplo un supervisor que no es account_admin) sigue siendo una decisión de producto abierta de nuestro lado: hoy pierde estas escrituras, y apagar el ajuste en una cuenta devuelve el comportamiento anterior. No construyas sobre ninguna de las dos respuestas como definitiva.
Titularidad del número
POST /v1/dids registra un número como autodeclarado: no prueba que sea tuyo. Cada DID lleva verified_at y verification_method (house, otp o admin); ambos son null hasta que se prueba la titularidad, y los números house (inventario de la plataforma) nacen verificados. Sólo la plataforma verifica un número customer, con POST /v1/admin/tenants/{id}/dids/{did_id}/verify (adminVerifyDid), que es también la única vía por la que se escribe attestation. El tenant no puede verificar sus propios números. (Fuente: schema DID y adminVerifyDid en core:openapi/dialer-v1.yaml; core:changelog.d/core-2415.md.)
Lo que hace un número sin verificar depende de la llave por tenant did_ownership_proof, que fija la plataforma:
| Valor | Un número customer sin verificar… |
|---|---|
off | se usa como antes; no se comprueba nada. |
observe (por defecto) | se sigue usando; la plataforma sólo cuenta el uso. |
enforce | se rechaza: una campaña no marca ni envía desde él, y POST /v1/sms/messages responde 422 did_unverified. |
Dos lugares lo muestran en enforce:
POST /v1/sms/messagescon unfrom(o un remitente por defecto del proveedor) que es un númerocustomernunca verificado →422 did_unverified.send_unavailable_reasonenGET /v1/campaigns/{id}/stats(y el framecampaign.stats) valedid_unverifiedcuando los únicos números activos desde los que enviaría la campaña están registrados pero sinverified_at. Pide a soporte que los verifique. (Fuente: cuerpo desendSmsy el enum desend_unavailable_reasonencore:openapi/dialer-v1.yaml.)
Exportaciones CSV
Todo CSV que core devuelve en /v1 se escribe con una sola función, Dialer.CSV, y una celda cuyo primer carácter es =, +, -, @, un tabulador, un retorno de carro, un salto de línea, o los equivalentes de ancho completo =, +, -, @ se neutraliza: se entrega con un ' delante, para que Excel y Sheets la lean como texto y nunca la ejecuten como fórmula. La guarda corre antes del entrecomillado de RFC 4180, así que una celda hostil que además lleve comas o comillas sale prefijada y entrecomillada. No tiene excepción para los números: un número E.164 sin máscara sale como '+1312…, y un valor negativo como texto. El prefijo es parte de la celda; quítalo si procesas el archivo. Lo mismo rige en el CSV que el navegador arma en la consola. Mira el constructor de reportes para el comportamiento exacto de ?format=csv allí.
Exportación masiva de grabaciones: 422 export_too_large
POST /v1/recording-exports toma los mismos filtros que GET /v1/recordings y responde 202 con el trabajo en export (export.job_id). Cuando los filtros coinciden con más grabaciones de las que admite el límite de filas de la plataforma, la petición se rechaza con 422 y code: "export_too_large"; no se crea ningún trabajo. El error añade dos enteros, max_rows (el límite) y matching_rows (cuántas grabaciones coinciden con los filtros). La exportación se rechaza, nunca se recorta: una exportación terminada siempre contiene todas las filas que coinciden. Acota los filtros y envíala de nuevo. La consola lee ambas cifras y las muestra.
Estadísticas
GET /v1/stats/summary
Contadores operativos de hoy: calculados a partir del CDR de solo anexado (ventana del día UTC) + el registro de asientos en vivo. Verdad reconstruible, no estado en caché.
json
{
"date": "2026-06-11",
"window": "utc_day",
"calls_today": 412,
"connected_today": 301,
"connect_rate": 0.7306,
"agents_online": 4,
"agents_available": 2,
"campaigns_running": 1
}GET /v1/stats/promises
Resumen del pipeline de PTP:
json
{
"open": { "count": 14, "amount_cents": { "USD": 412500, "MXN": 0 } },
"kept_rate_30d": 0.62,
"window_days": 30
}- Las monedas nunca se suman entre sí.
kept_rate_30dse calcula sobre las promesas ya resueltas —cumplidas o incumplidas— conpromised_dateen los últimos 30 días;nullsignifica que todavía no se ha resuelto ninguna.
GET /v1/stats/penetration
Analítica de penetración a nivel de persona. Parámetros de consulta: campaign_ids=a,b (UUIDs separados por comas; también se acepta campaign_id=), from/to (límites RFC 3339 sobre started_at, por defecto todo el tiempo). Sin campaign_ids (o vacío) el informe cubre todas las campañas del tenant, con un único total a nivel de persona; valores que no son UUID o más de 100 ids → 400. window=7d|30d|90d|all responde los mismos totals / by_campaign sobre una ventana relativa a ahora, servida desde una instantánea de corta vida derivada del CDR: la respuesta añade window y as_of (el instante en que se calcularon las cifras), y una instantánea más vieja que el max_age del servidor (60 s por defecto) nunca se sirve — el informe se calcula en vivo. window es excluyente con from/to y con granularity=day (422).
json
{
"totals": {
"campaign_id": null,
"unique_accounts": 1000,
"unique_consumers": 940,
"attempted": 500,
"reached": 250,
"effective_contact": 250,
"attempted_pct": 50.0,
"reached_pct": 25.0,
"effective_contact_pct": 25.0
},
"by_campaign": [ { "campaign_id": "c1a2b3c4-...", "...": "same row shape" } ]
}Definiciones (deduplicación a nivel de persona: cada deuda cuenta una vez):
attempted— ≥ 1 intento original.reached— cualquier disposición que no sea de fallo, o una conversación.effective_contact—answered_humano una conversación.
GET /v1/stats/abandonment
El informe de abandono de todas las campañas del tenant en una sola respuesta — el mismo informe que GET /v1/campaigns/{id}/abandonment da por campaña. { "as_of": "…", "campaigns": [ … ] }, un informe por campaña ordenado por nombre de campaña. Igual que window= en penetración, se sirve desde una instantánea de corta vida: as_of es cuándo se calculó, y una instantánea más vieja que max_age (60 s por defecto) nunca se sirve. La ruta por campaña sigue en vivo y es el registro; esta es la vista de conjunto.
GET /v1/stats/contact-windows
¿Cuándo contesta la gente? La tasa de contacto de cada hora local del consumidor —la hora del reloj de pared donde vive, no la hora de tu servidor—, más la mejor ventana de dos horas para llamar y cuántos números de teléfono tienes por persona.
Parámetros de consulta: from/to (RFC 3339, por defecto los últimos 30 días; la ventana es [from, to)), campaign_id opcional. Una fecha o un campaign_id mal formados → 400; from no anterior a to → 422; una ventana de más de 90 días → 422; un campaign_id que no es una de tus campañas → 404.
json
{
"from": "2026-09-04T00:00:00Z",
"to": "2026-10-04T00:00:00Z",
"campaign_id": null,
"basis": "consumer_local_hour",
"min_attempts": 50,
"hours": [
{ "hour": 0, "attempts": 0, "contacts": 0, "contact_rate": null },
"... 24 entries, hour 0-23, always all present ...",
{ "hour": 10, "attempts": 212, "contacts": 61, "contact_rate": 0.2877 }
],
"unknown": { "attempts": 14, "contacts": 3, "no_zone": 9, "ambiguous_zone": 5 },
"best_window": { "start_hour": 17, "end_hour": 19, "attempts": 388, "contacts": 131, "contact_rate": 0.3376 },
"best_window_reason": null,
"phones_per_person": { "persons": 940, "phones": 1411, "average": 1.5 }
}- Un intento es una llamada que la plataforma hizo ella misma (no entrante, no una pierna de transferencia externa, no una llamada que nunca salió). Un contacto es un intento que terminó en
answered_human;contact_rateescontacts / attempts, ynullpara una hora sin intentos. - La hora sale de la zona horaria que usó la compuerta de horas de silencio cuando decidió que la llamada era legal. Una llamada cuyo número abarca varias zonas (o cuya zona declarada discrepa de él) no tiene una hora local conocible: se cuenta en
unknown(ambiguous_zone, ono_zonecuando no se registró ninguna zona) y nunca se reparte entre las horas. best_windowes el par de horas consecutivas (start_hour,end_hour = start_hour + 2, sin pasar nunca de la medianoche) con la tasa combinada más alta, y cada una de las dos horas debe tener al menosmin_attemptsintentos (el ajuste del tenantcontact_window_min_attempts, por defecto 50). Los empates van a la hora más temprana. Sin datos suficientes esnullybest_window_reasondice por qué —insufficient_attemptsono_resolved_attempts— en lugar de elegir una hora a partir de ruido.phones_per_persones el número medio de teléfonos distintos por persona entre las cuentas activas (nopaid,settledniclosed); una persona sin teléfono cuenta como cero. Cubre todo el tenant e ignorafrom,toycampaign_id.averageesnullcuando no hay cuentas activas.
Evidencia de cumplimiento
Los artefactos durables, de solo anexado, que convierten «cumplimos las reglas» en «aquí está la fila que lo prueba». Cada número es un conteo sobre tablas de escritura única, nunca una estimación.
GET /v1/stats/blocked?from&to&campaign_id
El resumen de marcados bloqueados: conteos por compuerta + una serie diaria sobre la evidencia durable de gate_blocks (una fila por deuda-compuerta-campaña-día UTC que el motor de cumplimiento SE NEGÓ a marcar, escrita antes de saltarse el lead).
json
{"from": "...", "to": "...", "campaign_id": null, "total": 5,
"by_gate": {"quiet_hours": 4, "dnc_listed": 1},
"daily": [{"date": "2026-06-01", "total": 3, "by_gate": {"quiet_hours": 2, "dnc_listed": 1}}]}from/to RFC 3339 sobre occurred_at (por defecto los últimos 30 días UTC); campaign_id es un UUID opcional. El vocabulario de gate son los códigos de motivo del motor (tcpa_no_consent, quiet_hours, regf_7in7, regf_post_contact, state_limit, dnc_listed, consent_revoked, cease_and_desist, number_reassigned, tenant_policy).
GET /v1/reports/violations-prevented?from&to
El artefacto de cumplimiento empaquetado: totales por compuerta, un desglose por campaña, las versiones de política en vigor y filas de evidencia de muestra (enmascaradas). Añade ?format=csv (o Accept: text/csv) para el archivo del informe, que lleva su propio x-artifact-sha256.
GET /v1/debts/{id}/defense-packet
UN paquete JSON de evidencia sellado para una deuda, ensamblado en una sola transacción (instantánea consistente) a partir de cada tabla durable: el historial CDR completo (originales + correcciones, cada una con su compliance_snapshot congelado), las anclas de conversación de Reg F, cada rechazo de compuerta, el estado de consentimiento, DNC (tenant/global/reasignado), cese de comunicaciones, promesas, rellamadas y acciones de supervisión.
json
{"packet": "litigation_defense", "packet_version": 1, "...": "...",
"integrity": {"algorithm": "sha256", "digest": "<hex>",
"canonicalization": "sorted keys, no insignificant whitespace, ..."}}Evidencia de manipulación: integrity.digest es un SHA-256 del JSON canónico del sobre SIN la clave integrity: cualquier tercero puede recanonicalizar y volver a calcular el hash para verificarlo. Añade ?format=zip para obtener el paquete como archivo descargable (la respuesta lleva x-artifact-sha256).
Archivar un paquete de defensa y verificar su sello de tiempo sin conexión
Estado de publicación: las tres rutas de archivo están publicadas en core
v0.88.0y están en la Referencia de la API generada.
Archivar es una operación aparte y explícita. Un POST /v1/debts/{id}/defense-packet/archives vacío construye el mismo ZIP que GET /v1/debts/{id}/defense-packet?format=zip, sella en el tiempo el SHA-256 de esos bytes del ZIP, los escribe en el almacén de archivo, lee de vuelta el estado de retención y solo entonces registra una atestación de solo anexado. Si el almacén, la autoridad de sellado de tiempo o esa lectura de vuelta fallan, la respuesta es 503 y no se escribe ninguna fila de atestación. Con el archivo deshabilitado, las tres rutas de archivo responden 404. (Contrato: la operación archiveDefensePacket en core:openapi/dialer-v1.yaml.)
El objeto archivado es, por tanto, el paquete de litigio en ZIP descrito arriba: su packet.json lleva la deuda, los contactos, los intentos de llamada, las conversaciones, los rechazos de compuerta, la evidencia de consentimiento y DNC, los registros de cese de comunicaciones, las promesas, las rellamadas, las acciones de supervisión, las versiones de política y el propio objeto integrity del paquete. (Campos del contrato y respuesta ZIP: la respuesta ZIP de la operación getDefensePacket en core:openapi/dialer-v1.yaml.)
GET /v1/debts/{id}/defense-packet/archives devuelve las atestaciones de la más reciente a la más antigua y no incluye en línea el token RFC 3161. Cada fila identifica los bytes archivados con artifact_sha256, lleva el integrity_digest del paquete y registra el almacén, el resultado de retención y la hora de la TSA. (Operación de lista: la operación listDefensePacketArchives en core:openapi/dialer-v1.yaml; campos de la fila: el schema DefensePacketArchive.)
Lee el resultado de retención como dos mecanismos, sin elevar uno al otro:
- Retención del objeto:
retention_modeesCOMPLIANCEoGOVERNANCE;version_ididentifica la versión almacenada del objeto yretain_untiles la hora que confirmó el almacén. Son hechos devueltos por el almacén; la API no afirma queGOVERNANCEtenga la semántica deCOMPLIANCE. (Campos: las propiedadesversion_id,retention_modeyretain_untildel schemaDefensePacketArchiveencore:openapi/dialer-v1.yaml.) - Retención por regla del bucket:
retention_modeesbucket_lock. En ese casoversion_idyretain_untilsonnull, y manda la regla del bucket. Llámala retención configurada, nuncaCOMPLIANCEni retención inmutable. (Campos: las propiedadesversion_id,retention_modeyretain_untildel schemaDefensePacketArchiveencore:openapi/dialer-v1.yaml.)
Descargar y verificar sin la plataforma
Necesitas el id de la deuda, el id del archivo de la operación de lista y el certificado raíz de la TSA de una fuente en la que confíes de forma independiente. Si la TSA usa certificados intermedios, guarda también su cadena publicada como tsa-intermediates.pem. La operación de atestación devuelve tres cosas: la fila como attestation, una TimeStampResp DER en base64 como tsr, y una URL prefirmada de corta vida del ZIP en artifact.url, con su vencimiento en artifact.expires_at. (Contrato de la respuesta: la operación getDefensePacketAttestation en core:openapi/dialer-v1.yaml.)
Obtén el kit y descarga de inmediato el ZIP mientras la URL es válida.
bashBASE_URL="https://api.example.com" DEBT_ID="<debt-uuid>" ARCHIVE_ID="<archive-uuid>" TOKEN="<bearer-token>" curl --fail --silent --show-error \ -H "Authorization: Bearer ${TOKEN}" \ "${BASE_URL}/v1/debts/${DEBT_ID}/defense-packet/archives/${ARCHIVE_ID}/attestation" \ --output kit.json jq -er '.tsr' kit.json | base64 --decode > artifact.tsr curl --fail --location --silent --show-error \ "$(jq -er '.artifact.url' kit.json)" \ --output artifact.zipCompara los bytes descargados con el digest de la atestación. Una discrepancia significa que este ZIP no es el artefacto que nombra el kit; detente ahí. El campo es el SHA-256 de los bytes del ZIP archivado y el digest que la autoridad selló en el tiempo. (Contrato del campo: las propiedades
artifact_sha256eintegrity_digestdel schemaDefensePacketArchiveencore:openapi/dialer-v1.yaml.)bashexpected="$(jq -er '.attestation.artifact_sha256' kit.json)" actual="$(sha256sum artifact.zip | awk '{print $1}')" test "${actual}" = "${expected}"Verifica la respuesta DER contra ese mismo digest y la cadena de confianza de la TSA.
bashopenssl ts -verify \ -digest "${actual}" \ -in artifact.tsr \ -CAfile tsa-root.pem \ -untrusted tsa-intermediates.pemSi la TSA no tiene certificados intermedios, omite la última línea
-untrusted tsa-intermediates.pem. La aceptación vincula el digest del ZIP con la respuesta RFC 3161 bajo la cadena de TSA suministrada. El contrato también expone la hora firmada de la autoridad comoattestation.tsa_gen_time. (Contrato de verificación y campo de hora: la descripción de la operacióngetDefensePacketAttestationy la propiedadtsa_gen_timedel schemaDefensePacketArchive.)
Esta comprobación no prueba de forma independiente que el almacén siga reteniendo el objeto. Prueba el sello de tiempo sobre estos bytes del ZIP; la retención sigue siendo el retention_mode, retain_until y version_id que informa el almacén, con los límites de bucket_lock indicados arriba. (Semántica de la atestación: las propiedades de retención del schema DefensePacketArchive en core:openapi/dialer-v1.yaml.)
PUT /v1/recording-policy: el techo de retención
PUT /v1/recording-policy reemplaza la política de grabación del tenant (enabled, retention_months, channels, format). Puede escribirla una llave dd_ o un login account_admin; un login member recibe 403 role_forbidden. retention_months tiene un piso y un techo, y ambos son 422 unprocessable:
- Piso: el piso efectivo de retención del tenant, que sale de los tipos de negocio declarados (ver Retención por tipo de negocio).
- Techo: lo que incluye el plan,
max(3, piso)meses, más 12 meses por cada bloque de retención extra que el tenant haya comprado. Sin bloques comprados, el techo es el propio piso. El mensaje nombra el techo:retention_months must be at most N months: the retention your plan includes plus the 12-month blocks you have bought.
Una política ya guardada por encima de su techo conserva su valor: puede volver a guardarse con ese valor o con uno menor, nunca subirse. Los bloques los registra la plataforma cuando se venden; esta API no tiene ningún campo para comprar uno. El techo se aplica mientras el ajuste recording_retention_cap de la plataforma esté activo, y lo está por defecto. Fuente: core:lib/dialer/recordings/policy.ex (max_retention_months/2) y core:lib/dialer/billing/plan.ex.
Medición del almacenamiento. El día 1 de cada mes la plataforma registra, por cada tenant con grabaciones guardadas, un evento storage_gb_month con los GiB que guardaba al cierre del mes anterior. Es solo una medición: el evento lleva precio 0 e importe 0. No es una línea de GET /v1/billing/summary y ningún endpoint de la API lo devuelve. Fuente: core:lib/dialer/recordings/storage_meter_job.ex.
Registrar el consentimiento que tienes
El gate de consentimiento y el de voz artificial (IA, TTS, IVR, encuesta) leen el consentimiento registrado de cada número. Hasta estas rutas, la única forma de que un consentimiento llegara ahí era que el consumidor contestara START por SMS. Estas tres rutas te permiten registrar el consentimiento que ya tienes, con su prueba. Contrato: /v1/consents/{phone} y /v1/consents/import en openapi/dialer-v1.yaml de core.
| Ruta | Qué hace |
|---|---|
PUT /v1/consents/{phone} | Registra el consentimiento de un número. Repetir la llamada reemplaza la fila (upsert por número). |
DELETE /v1/consents/{phone} | Revoca el consentimiento. Siempre aplica, también sobre un número sin consentimiento registrado, y es idempotente: desde entonces el gate rechaza el número (consent_revoked) hasta que se registre un consentimiento con fecha posterior a la revocación. |
POST /v1/consents/import | Registra muchos números desde un cuerpo text/csv. |
- Quién puede escribir. Una llave
dd_o un login del dashboard con rolaccount_admin. Un loginmemberrecibe403 role_forbiddenen las tres rutas y no se guarda nada; un token de widget o un tenant suspendido recibe403 forbidden. - Qué lleva una escritura.
channel(voice,smsoany),consented_at(ISO 8601 con desfase),source(hasta 200 caracteres: dónde se obtuvo el consentimiento) yevidence_ref(hasta 2 000 caracteres: dónde vive la prueba — un id de grabación, un formulario firmado, una URL). Los cuatro son obligatorios;text_version(hasta 200) es opcional. Sinevidence_refel consentimiento no se acepta. La plataforma guarda esa referencia tal como la das: no la abre ni la verifica. - El número viaja en la ruta (E.164, con el
+como%2Bo no, o un número NANP sin prefijo), así que llega a los registros de acceso de cualquier proxy delante de la API. Las respuestas nunca lo devuelven entero:***0100. - La fecha se valida.
consented_atno puede estar en el futuro (422) ni ser posterior al día en que el número se registró por primera vez en tus contactos (422). Un número cuyo consumidor revocó admite un consentimiento nuevo sólo siconsented_ates posterior a la revocación; si no,409 consent_revoked. Si la revocación no lleva fecha, sólo la levanta el consumidor, conSTART. Un consentimiento que obtuviste después de importar el número no se puede registrar con su fecha verdadera por esta ruta. - Historial. Cada escritura y cada revocación queda en el historial de sólo anexar de bajas y sellada en la cadena de cumplimiento, en la misma transacción. La fila del número es «la última escritura gana»: una revocación posterior le quita la prueba a la fila, y el historial es donde sobrevive.
- Importación CSV. Una fila por número:
number,channel,consented_at,source,evidence_ref[,text_version]. La cabecera es opcional; se saltan las líneas en blanco y las que empiezan por#; un campo con coma va entre comillas dobles. Límites: 1 000 000 de bytes de cuerpo (413 payload_too_large), 1 000 filas de datos y como máximo 100 entradas enrejected(rejected_countlleva el total real). Otro tipo de contenido es415. Cada fila se valida como la escritura individual. La respuesta es{received, recorded, rejected_count, rejected: [{line, value, reason}]}con el valor enmascarado: un200no significa que se registraron todas las filas; mandarejected_count. Una fila rechazada deja su número sin consentimiento, así que los gates siguen bloqueándolo. Las importaciones se frenan igual que las de deudas (429).
bash
curl -X PUT "https://api.dialerdigital.example/v1/consents/%2B13125550100" \
-H "Authorization: Bearer $DD_API_KEY" -H "Content-Type: application/json" \
-d '{"channel":"voice","consented_at":"2026-01-15T14:30:00Z","source":"web_form",
"evidence_ref":"https://forms.example.com/submissions/8841","text_version":"tcpa-v3"}'
# -> {"consent":{"number":"***0100","status":"granted","channel":"voice", ...}}Lo que esto no hace, dicho tal cual:
- Un consentimiento no abre los demás gates: DNC, horas de silencio, Reg F y el resto siguen decidiendo cada llamada. Tampoco cambia el tipo de línea de un número.
- Ningún gate lee todavía
channel: un consentimiento registrado comosmsabre también la voz, igual que ya lo haceSTART. - La prueba mínima que exige la plataforma y el trato de un
landlineque declaras al importar siguen sin decidirse; hoy unlandlinedeclarado todavía relaja la comprobación de consentimiento. No des ninguno de los dos puntos por definitivo. - El paquete de defensa aún no devuelve
evidence_refnitext_versionpor evento: la prueba está en el historial y en el sello.
Baja por SMS de empresa. Un STOP que llega por un proveedor de SMS de empresa (sin sede) se escribe en la lista interna de no llamar y revoca el consentimiento en todas las sedes de la cuenta. Un STOP por un proveedor de sede se queda en esa sede. Nunca cruza a otra cuenta. Ver Mensajería SMS.
POST /v1/privacy/erasure-requests: borrar a una persona
Pide a la plataforma que anonimice los datos personales que guarda de una persona de tu cuenta, dada por phone (E.164) o consumer_ref (exactamente uno de los dos). Lo acepta una llave dd_ o un login del dashboard con el rol account_admin; un login member recibe 403 role_forbidden.
json
{"phone": "+13055550123", "dry_run": true}Simulacro (el valor por defecto). Con dry_run ausente o true la petición lee, clasifica y registra la solicitud, y no cambia ninguna fila. El informe lista cada categoría de datos hallada (found, would_anonymize), lo que se conserva con su motivo y la fecha hasta la que se guarda, y las copias fuera de la base principal en derived_copies. Úsalo primero: así ves qué haría un borrado antes de hacerlo.
Ejecución (dry_run: false). La misma petición anonimiza a la persona de verdad. En una sola pasada:
- purga el audio, la transcripción, los segmentos y el resumen de las grabaciones de la persona que ya pasaron su retención y no están bajo
legal_hold; - vacía la transcripción de sus conversaciones de IA vencidas, sólo si
ai_conversation_purge_enabledestá activa en la cuenta; - anonimiza el contenido personal (números, últimos 4 dígitos, nombres, números de cuenta, cuerpos de mensajes, notas, correos de contacto) de todo otro registro más viejo que el piso de registros de tu cuenta, que nunca baja de 3 meses. Las filas se quedan en su sitio; sólo se va lo que identifica a la persona;
- borra todas las direcciones del canal de email de la persona, sea cual sea su antigüedad (sin piso de registros): pasan a
address: nullconineligible_reason: address_erased(canal de email); - borra las puntuaciones de contactabilidad de sus números, salvo la que esté en cuarentena durable, y vacía el contenido de sus entregas de webhook ya liquidadas (una entrega aún pendiente se conserva);
- deja en blanco los últimos 4 dígitos en la copia analítica de los registros de llamadas (
derived_copiesinformamutated,nothing_to_mutateonot_configured). Si esa copia no se puede cambiar, la petición falla con503 erasure_failedy no se anonimiza nada: repítela.
Lo que queda sellado. La ejecución escribe una lápida en la cadena de auditoría de cumplimiento de la cuenta, en la misma transacción que la anonimización, y devuelve su posición como chain_seq. La cadena sigue verificando: las filas se anonimizan, nunca se borran. La lápida y el registro de la solicitud guardan a la persona sólo como HMAC, nunca el teléfono ni la referencia.
Lo que nunca toca. La supresión (listados de no llamar, órdenes de cese, consentimientos revocados e historial de supresión), las grabaciones bajo legal_hold o dentro de su retención y todo registro más joven que el piso. Aparecen bajo retained en el informe. Lo que está dentro del piso se puede borrar más tarde: vuelve a pedirlo por consumer_ref cuando el piso haya vencido, porque una vez borrado el contacto el teléfono ya no encuentra a la persona.
La ejecución está apagada por defecto. Dos ajustes protegen la ruta y ambos están apagados hasta que el operador de la plataforma los enciende; no puedes activarlos desde esta API:
| Petición | Ajuste | Si está apagado |
|---|---|---|
| Simulacro | privacy_erasure_enabled (por cuenta) | 403 privacy_erasure_disabled, no se escribe nada |
dry_run: false | privacy_erasure_execute_enabled | 403 privacy_erasure_execution_disabled, no se escribe nada |
No construyas un flujo que dé por hecha la ejecución: pregunta al operador de la plataforma si está encendida en tu cuenta.
La respuesta es 202 con erasure_request: su id, dry_run y el report (categories, derived_copies, cutoff, records_floor_months, chain_seq y execution, que vale simulated o executed). Cada petición deja un registro de sólo añadir; si no se puede escribir, no se devuelve informe (503 audit_unavailable). Repetir una petición es seguro. Fuente: la operación createErasureRequest de core:openapi/dialer-v1.yaml.
Facturación y plan
El plan de suscripción y los cargos facturables.
El catálogo por nivel es DATOS, no código: vive en la tabla plan_tiers y la escalera vendible es starter $25, growth $69, scale$119, enterprise $199 por asiento/mes, y cada fila lleva su propia asignación mensual (included_minutes, included_sms_segments, included_ai_minutes, included_dids). Este repo incorpora ese catálogo en data/system-catalog.json, con su procedencia en data/system-catalog.SOURCE.json, y npm test falla si algún precio de estas páginas se desvía de él.
El mínimo de asientos de facturación es por plan (Dialer.Billing.Plan.seat_minimum/1, macro-tasks#870): starter 1, growth 1, scale 3, enterprise 25. La asignación de la fila es por cuenta, no por asiento: con 3 o más asientos facturables la cuenta obtiene la asignación de la fila tal cual; con 1 o 2 obtiene asientos ÷ 3 de ella (los minutos, los segmentos de SMS y los minutos de IA se truncan hacia abajo, el SMS a segmentos enteros, y los DIDs incluidos se truncan con un mínimo de 1). El resumen, el cierre del mes y el freno de gasto leen el mismo cálculo.
Lo que SÍ es código de toda la cuenta (Dialer.Billing.Plan): el excedente de DIDs a $25 por cada 10 DIDs monitoreados por encima de la cuota del nivel, y un complemento de IA de voz medido a $0.25/min conectado con un mínimo de $200/mes mientras está habilitado. Desde core#893 esa línea aplica además el nivel por volumen publicado y el descuento de socio de diseño, siempre en este orden: asignación del nivel → nivel por volumen → descuento de socio de diseño → mínimo mensual. El mínimo se compara el ÚLTIMO, de modo que un descuento no puede dejar una factura por debajo de él. El complemento se ofrece en los niveles cuya fila del catálogo declara una asignación included_ai_minutes —growth, scale y enterprise—; starter deja el campo vacío y no es elegible.
PATCH /v1/tenant
Autoedición de la cuenta. El cuerpo acepta name y nada más; cualquier otra clave se ignora y se devuelve el tenant actualizado.
No hay cambio de plan por autoservicio en esta API. Los campos que determinan la factura —tier, voice_ai_enabled y seats— deciden el importe, así que un bearer de tenant que pudiera escribirlos sería un vector directo de pago a la baja (bajar el nivel o desactivar el complemento al final del periodo → factura más barata). Viven en la superficie de administración de la plataforma (PATCH /v1/admin/tenants/{id}), tras el token de administración de la plataforma, que ninguna credencial de tenant puede emitir. Para cambiar el plan, los asientos o el complemento, pregunta a tu contacto de la plataforma.
GET /v1/billing/summary?from&to
Las líneas facturables del periodo, cada importe calculado en el servidor a partir del estado real (asientos ocupados, DIDs monitoreados, minutos de IA medidos):
json
{
"tier": "growth", "tier_label": "Growth",
"voice_ai_enabled": true, "currency": "USD",
"period": { "from": "...", "to": "..." },
"line_items": [
{ "kind": "seats", "billable_seats": 5, "unit_price_usd": 69, "amount_usd": 345 },
{ "kind": "did_overage", "overage_dids": 10, "included_dids": 2, "amount_usd": 25 },
{ "kind": "voice_ai", "enabled": true, "billable_minutes": 900.0,
"allowance_pool": "ai_minutes", "included_allowance": 100,
"overage_quantity": 800.0, "gross_amount_usd": 225.0,
"subtotal_usd": 200.0, "amount_usd": 200.0 }
],
"total_usd": 570.0
}El elemento voice_ai de arriba está abreviado. El tenant está en growth, cuya fila del catálogo incluye 100 minutos de IA: 900 minutos medidos suman $225 brutos, la asignación descuenta su parte proporcional y quedan 800 minutos facturables a $200. Ese periodo queda por debajo del umbral de volumen y no lleva ninguna concesión de socio de diseño, así que el subtotal es lo que se factura; un periodo que cruzara cualquiera de los dos llevaría además los ejes volume_tier_* y de descuento de la línea.
Los asientos y el excedente de DIDs son el recurrente mensual actual; voice_ai se mide sobre [from, to] (por defecto: los últimos 30 días UTC). Añade ?format=csv para el artefacto de facturación (con x-artifact-sha256).
Pasada la asignación: el exceso se descuenta del saldo prepagado
Cuando un tenant con suscripción agota una asignación, el trabajo continúa y solo el exceso se carga a su saldo prepagado, al precio estampado en el propio evento de uso (el precio publicado del catálogo, nunca una constante). La ventana es el mes calendario UTC, la misma que usa el cierre del mes; los pools son minutos de voz (salientes + entrantes), segmentos de SMS y uso de IA. Fail-closed: sin saldo disponible, sin tarifa o sin manera de decidir, el freno de gasto detiene el tráfico con plan_exhausted, como antes. Una llamada autorizada dentro de la asignación que cruza el límite puede dejar el saldo ligeramente negativo por esa fracción.
Pago por uso
El modo de entrada es un asiento con todo el consumo descontado del saldo prepagado y sin asignación. GET /v1/billing/tiers (una clave dd_ o un inicio de sesión account_admin) lo lleva como el objeto pay_as_you_go junto a tiers: scheme (prepaid), tier (starter), seats (el mínimo de asientos del plan), seat_price_usd (leído de la fila del catálogo), billed_from (balance) y un bloque included que declara a propósito la ausencia de las tres asignaciones (minutes: "0", sms_segments: 0, ai_minutes: "0"). El objeto es null cuando el nivel de entrada no está a la venta.
GET /v1/billing/cost?group_by&from&to
El costo bruto del libro de uso, agrupado por una dimensión por petición: group_by=campaign, carrier o agent. Responde a «qué campaña, operador telefónico o agente está gastando ahora mismo»; no es la factura. La asignación incluida, el nivel por volumen, el descuento de socio de diseño y el mínimo mensual se aplican en la factura y no se reparten entre los grupos: el bloque reconciliation pone gross_total junto a las líneas de GET /v1/billing/summary sobre la misma ventana y lista lo que no se asigna.
- Tras una bandera, apagada por defecto. Solo los tenants con
billing_cost_enabledENCENDIDA pueden leerlo (un operador de la plataforma escribe la bandera). Con ella APAGADA, todo el que llama, con cualquier rol, recibe el mismo404 not_found, como si la ruta no existiera. - Quién. Una clave de API
dd_o un inicio de sesión del dashboard con el rolaccount_admin. Cualquier otro inicio de sesión recibe403 role_forbidden, con el mismo cuerpo envíe lo que envíe. - Ventana.
fromytoson instantes RFC 3339 obligatorios,from < to, con como máximo 31 días de distancia (400 bad_requesten caso contrario), semiabierta como/v1/billing/summary. El libro está indexado por día UTC, así que una ventana intradía suma sus días UTC completos (ledger_daysdice cuáles). - No se descarta nada. Cada fila del libro cae en exactamente un grupo, o en el cubo con
key: nullyunattributed: true. Las monedas nunca se mezclan: cada grupo y cada total lleva la suya.billed_byesplatform(troncal propio de la plataforma),tenant_carrier(el operador telefónico propio del cliente) onull. - Cuando no puede responder. Si parte de la ventana no está cerrada en el resumen diario (y no es uno de los dos días UTC más recientes), la respuesta es
503 unavailable; si los grupos no sumaron el total,500 cost_unreconciled. Nunca se publica un número parcial.
json
{
"group_by": "campaign",
"from": "2026-10-01T00:00:00Z",
"to": "2026-10-08T00:00:00Z",
"ledger_days": { "from": "2026-10-01", "to": "2026-10-07" },
"sources": { "rollup": 5, "direct": 2, "empty": 0 },
"groups": [
{ "key": "<campaign-uuid>", "unattributed": false, "currency": "USD", "billed_by": "platform", "amount": "41.2500", "events": 310 },
{ "key": null, "unattributed": true, "currency": "USD", "billed_by": null, "amount": "8.7500", "events": 52 }
],
"gross_total": [
{ "currency": "USD", "amount": "50.0000", "events": 362 }
],
"reconciliation": {
"basis": "ledger_gross",
"groups_sum": [
{ "currency": "USD", "amount": "50.0000", "events": 362 }
],
"invoice": {
"status": "available",
"currency": "USD",
"total_usd": "870.0",
"lines": [
{ "kind": "seats", "amount_usd": "595", "gross_amount_usd": null },
{ "kind": "house_minutes", "amount_usd": "0", "gross_amount_usd": "41.2500" }
]
},
"not_allocated": ["included_allowance", "volume_tier", "discount", "monthly_minimum"],
"note": "gross_total is the sum of the usage ledger, not the amount billed; the included allowance, volume tier, discount and monthly minimum are applied on the invoice and are not split across groups"
}
}El ejemplo es el de la especificación incorporada (billingCost en openapi/dialer-v1.yaml), con el UUID de campaña del primer grupo reemplazado por un marcador; el schema es BillingCost en la Referencia de la API. En la consola, la misma lectura alimenta la tarjeta Coste derivado de Facturación y plan (ver el módulo 17).
GET /v1/stats/ai-usage?from&to&campaign_id
La alimentación de IA de voz medida a partir de la cual se factura el complemento: segundos/minutos facturables totales, número de llamadas, un cargo estimado a la tarifa configurada y una serie diaria. Una fila por llamada de IA contestada, facturada exactamente una vez. ?format=csv para el artefacto.
El alta (aprovisionar un tenant nuevo + su primer administrador + una clave de arranque) es una operación de administración de la plataforma —
POST /v1/admin/tenants, protegida por un token de administración aparte, no por una clave de tenant. Queda fuera del alcance de las integraciones de cara al tenant.
Métricas (puerto 9568)
GET /metrics sirve texto de Prometheus en un puerto aparte (9568), sin autenticación: mantenlo fuera del ingress público. Los contadores incluyen dialer_api_requests_total, dialer_api_request_duration_milliseconds_*, dialer_compliance_decision_count (allowed/reason_code), dialer_call_fsm_transition_count, dialer_pacing_throttle_count, dialer_runner_lead_blocked_count, dialer_runner_attempt_write_failed_count, dialer_cdr_pipeline_error_count y los contadores dialer_agents_* de checkin/checkout/transición. Sin etiquetas de tenant/llamada por diseño (cardinalidad acotada).
Códigos de error
Ver la tabla del sobre de error en el resumen. Mapa rápido: 400 bad_request (tipos/parámetros, fail-closed), 400 invalid (invariante de dominio), 401 unauthorized, 403 forbidden (tenant suspendido), 404 not_found (incluidos los ids de otro tenant y los que no son UUID), 409 conflict (máquinas de ciclo de vida/estado, duplicados, asientos ocupados), 415 unsupported_media_type, 422 unprocessable (contratos tipados: PTP, reglas de rellamada, filtros de rewind reservados), 500 internal.