Skip to content

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étodoRutaPropósito
GET/healthzSonda de vida (sin autenticación).
GET/v1/meEl tenant autenticado (incluye tier, voice_ai_enabled).
PATCH/v1/tenantAutoedición de la cuenta. SOLO name: el plan, los asientos y el complemento son superficie de la plataforma.
GET/v1/tenant/calling-windowEl horario de llamadas propio de la cuenta (null si no hay). Publicado en core v0.87.0.
PUT/v1/tenant/calling-windowFija el horario semanal de llamadas de la cuenta; solo lo estrecha. Clave dd_ o account_admin. Misma nota.
DELETE/v1/tenant/calling-windowElimina el horario de llamadas de la cuenta. Misma nota.
GET/v1/tenantsLista de un elemento: tu propio tenant.
GET/v1/tenants/{id}Tu tenant, o 404.
GET/v1/api_keysLista las claves de API.
POST/v1/api_keysEmite una clave (el texto en claro se muestra una sola vez).
DELETE/v1/api_keys/{id}Revoca una clave.
GET/v1/usersUsuarios del dashboard y sus invitaciones abiertas.
POST/v1/usersInvita a una persona al dashboard (202; sin contraseña).
DELETE/v1/users/{id}Deshabilita a un usuario (nunca lo borra).
POST/v1/users/{id}/enableVuelve a habilitar a un usuario deshabilitado.
POST/v1/auth/password-reset/confirmCanjea una invitación o un enlace de restablecimiento con la contraseña que elige la persona.
GET/v1/campaignsLista las campañas.
POST/v1/campaignsCrea 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}/startrunning + arranca el runner del bucle de marcado.
POST/v1/campaigns/{id}/resumeAlias de start.
POST/v1/campaigns/{id}/pausepaused, detiene el runner.
POST/v1/campaigns/{id}/stopcompleted (terminal).
POST/v1/campaigns/{id}/archivedraft|completed → archived.
GET/v1/campaigns/{id}/statsContadores en vivo del runner y del ritmo de marcado.
GET/POST/v1/campaigns/{id}/rewind/previewSimulacro del rewind.
POST/v1/campaigns/{id}/rewindReencola las deudas que coinciden.
GET/v1/ai/voicesEl catálogo gobernado de voces para agentes de IA; ?language= filtra. Publicado en core v0.87.0.
POST/v1/ai/agents/{id}/voice-previewUna 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}/testUn 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-segmentsSegmentos 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}/retireRetira un segmento (terminal).
POST/v1/debts/importImportación masiva de leads en JSON (idempotente).
GET/POST/v1/import-mappingsPlantillas 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}/usesLas importaciones que nombraron la plantilla, con la versión que usó cada una.
GET/v1/debts/{id}Deuda + contactos.
GET/v1/debts/{id}/call_attemptsHistorial 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-packetPaquete sellado de evidencia para litigio (JSON o ZIP).
POST/v1/debts/{id}/defense-packet/archivesArchiva el ZIP con retención y un sello de tiempo RFC 3161. Publicado en core v0.88.0.
GET/v1/debts/{id}/defense-packet/archivesLista las atestaciones de archivo de la deuda. Publicado en core v0.88.0.
GET/v1/debts/{id}/defense-packet/archives/{aid}/attestationObtiene 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_attemptsConsulta del CDR filtrada y paginada por keyset.
GET/v1/reports/cdrLa misma consulta del CDR como protojson canónico dialer.v1.CallDetailRecord.
POST/v1/call_attempts/{id}/dispositionResultado tipado del agente o de la IA (+ promesa para PTP).
GET/v1/promisesLista las promesas.
PATCH/v1/promises/{id}pending → kept|broken|cancelled.
POST/v1/callbacksPrograma una rellamada acordada con el consumidor.
GET/v1/callbacksVista de la cola / alimentación de vencidas del runner.
POST/v1/callbacks/{id}/completepending → done.
POST/v1/callbacks/{id}/cancelpending → cancelled.
POST/v1/callbacks/{id}/misspending → missed.
GET/v1/agentsPlantilla de agentes + presencia en vivo.
POST/v1/agentsCrea 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/checkinEl asiento pasa a available (por sip_extension; device_mode opcional).
POST/v1/agents/checkoutEl asiento pasa a fuera de línea.
POST/v1/agents/{id}/pauseEl asiento pasa a paused (cuerpo reason).
POST/v1/agents/{id}/resumepaused → available.
POST/v1/supervision/calls/{call_id}/listenMonitoreo silencioso de una llamada en vivo.
POST/v1/supervision/calls/{call_id}/whisperCoachea al agente (solo el agente lo oye).
POST/v1/supervision/calls/{call_id}/bargeTres vías (ambos lados oyen al supervisor).
POST/v1/supervision/calls/{call_id}/takeoverTransfiere al deudor al supervisor.
GET/v1/didsRegistro de DIDs (filtra por status).
POST/v1/didsRegistra 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}/healthSalud de respuesta/llamadas cortas calculada a partir del CDR.
GET/v1/stats/summaryContadores operativos de hoy.
GET/v1/stats/promisesResumen del pipeline de PTP.
GET/v1/stats/penetrationAnalítica de penetración a nivel de persona.
GET/v1/stats/contact-windowsTasa de contacto por hora local del consumidor, mejor ventana de llamada, teléfonos por persona.
GET/v1/stats/blockedResumen durable de marcados bloqueados (por compuerta + diario).
GET/v1/stats/ai-usageMinutos de IA de voz medidos (JSON o CSV).
GET/v1/reports/violations-preventedArtefacto de cumplimiento empaquetado (JSON o CSV).
POST/v1/privacy/erasure-requestsSimulacro (por defecto) o ejecución del borrado de una persona; la ejecución está apagada por defecto (detalle).
GET/v1/billing/summaryLí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 202 con 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 conflict solo para tu propia gente: la dirección ya es usuaria de tu cuenta, activa o deshabilitada; lo ves en tu propio GET /v1/users. Una deshabilitada se recupera con POST /v1/users/{id}/enable, no con una invitación nueva.
  • 400 bad_request si el cuerpo trae un password. 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_limited cuando 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_admin activo, invitar exige un inicio de sesión account_admin del dashboard (ddu_…): un inicio de sesión member y una clave dd_ reciben 403 role_forbidden y no se registra nada (core#2334, core#2449). Una cuenta sin account_admin activo conserva la postura anterior: la clave dd_ 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_by en GET /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 es 400 y no se registra nada.
  • Espera 202 con el cuerpo fijo, sin usuario y sin id: la persona no existe hasta que acepta. Sigue la invitación en GET /v1/users (invitations), y a la persona en users cuando acepte.
  • Lee 409 como «ya está en tu cuenta», no como «ocupada en otra parte»: una dirección ocupada por otra cuenta es ahora un 202 como 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 exactamente YYYY-MM-DDTHH:MM:SS, sin Z ni desfase UTC. Si vienen los dos, ends_at debe ser posterior a starts_at. Envía null para 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 draft a running cuando se alcanza starts_at, siempre que su plan permita un arranque y su ends_at no haya pasado también. Mueve una campaña de voz que está en running a completed, terminal, cuando se alcanza ends_at. No reinicia una campaña que una persona pausó o que la plataforma marcó como exhausted; 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_enabled para la cuenta. Mientras esté apagada, una campaña solo arranca con POST /v1/campaigns/{id}/start y la plataforma no la completa en ends_at.
  • Un arranque manual no perdona el final. Si una persona arranca una campaña después de su ends_at guardado mientras la capacidad está habilitada, la siguiente pasada del planificador la completa otra vez. Borra o mueve antes ends_at si 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 del tts_prompt de 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, y GET /v1/campaigns/{id}/stats informa prompt_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-disclosure acepta una clave de tenant (dd_) o un inicio de sesión account_admin; un inicio de sesión member recibe 403 role_forbidden (leerla con GET sigue abierto).
  • Sin optout_digit, no hay llamada. Una campaña de estos tipos necesita un optout_digit: una sola tecla DTMF, 0-9, * o #. Sin él, cada intento se bloquea antes de marcar como tenant_policy, bajo la regla tcpa.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. Fija optout_digit en 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 propio optout_digit al 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 el tts_prompt de 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-disclosure la acepta—, pero ninguna campaña TTS, IVR o de encuesta marca con ella, y GET /v1/campaigns/{id}/stats informa prompt_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_digit válido se sigue rechazando por intento con tcpa.artificial_voice.optout.v1 (arriba), con o sin marcador. La única excepción es una campaña que tiene la antigua bandera ai_voice: true y ninguna tecla: se juzga como la vía de IA, así que se detiene antes de marcar con el mismo optout_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 es 400 invalid nombrando el campo.
  • voicemail_message — tu texto, de hasta 2000 caracteres, con {optout_number} donde se pronuncia el número (400 invalid sin é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}/stats dice prompt_unavailable_reason: "prompt_rendering"; si falla, "ai_disclosure_render_failed", la campaña no marca y la conversión se reintenta sola. ai_disclosure_ready es true cuando el audio del texto actual está listo. Editar la divulgación la convierte de nuevo, y la campaña espera el audio nuevo. (Core v0.77.0 sirve ai_disclosure_render_failed y ai_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 y tenant_throttle_reason es ai_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_undeclared significa 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_ready en las estadísticas dice si está listo (lo sirve core v0.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 como ai_conversation, y el informe de abandono de 30 días la cuenta aparte como handoff_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/voices lista 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 clave dd_; ?language= filtra por el subtag principal (en-US selecciona en) y un idioma sin voces responde 200 con []. En POST y PATCH /v1/ai/agents, un voice.voice_id que no está en el catálogo es 422 unprocessable nombrando el id; un cuerpo sin voice_id es válido, y un agente guardado cuando el id era texto libre sigue funcionando: el id que ya almacena se sigue aceptando en PATCH. 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-preview no lleva cuerpo y responde 200 {"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). La url se 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 responde cached: true sin una generación nueva. Solo una petición que tiene que generar cuenta contra un presupuesto por cuenta: por encima, la respuesta es 429 rate_limited con Retry-After, y una petición en caché nunca se rechaza. 404 para un agente que no es tuyo, 422 para un agente sin voice.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, y 503 voice_preview_unavailable con la etapa que falla en error.reason cuando el servicio de voz no puede producir el audio: nunca recibes un archivo vacío. Un token de navegador ddw_ es 403. Publicado en core v0.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-ai v0.29.0). POST /v1/ai/agents/{id}/test recibe {"message", "session_id"} (session_id es opcional: omítelo para abrir un hilo y devuelve el que recibiste para continuarlo) y responde 200 {"session_id", "reply"}. Nunca es una llamada, nunca una fila de ai_conversations y 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_request si falta message o está vacío; si message pasa de 4.000 bytes (message is too long (max 4000 bytes); bytes, no caracteres); y si session_id pasa de 128 bytes (session_id is too long (max 128 bytes)). Fuente: core lib/dialer/voice_ai/sandbox_guard.ex:30-31, lib/dialer/api/v1_router.ex:15559-15566 y :15600-15616.
    • 403 synthetic_tenant para una cuenta de demostración sintética, que no gasta el modelo de la casa. Un token de navegador ddw_ también es 403. Fuente: lib/dialer/api/respond.ex:253-260, lib/dialer/api/v1_router.ex:15618.
    • 429 rate_limited con Retry-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 mismo session_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-239 y :263, lib/dialer/api/v1_router.ex:15591-15598.
    • 503 sandbox_budget_unavailable cuando 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_unavailable sigue significando que el runtime de IA no está configurado o no responde, y 502 bad_gateway que 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-ai src/voiceai/sandbox.py:47-50 y :119-125.

  • Pasar a live exige guardas explícitas (core#2459, ADR #298 de core; core CHANGELOG, 0.130.0). PATCH /v1/ai/agents/{id} con status: live comprueba que la policy del agente traiga max_settlement_pct, min_payment_cents y max_plan_months, y al menos una frase no vacía en prohibited; con take_payments: "card_on_file" pide además una entrada de handoff_rules cuyo trigger sea payment_over. El ajuste de la plataforma ai_agent_live_requires_guards (off, observe o enforce; por defecto observe) decide qué hace un hueco:

    • observe: 200 como siempre, con warnings: [{"code": "policy_incomplete", "message", "missing": [...]}], donde missing lista las claves ausentes entre max_settlement_pct, min_payment_cents, max_plan_months, prohibited y payment_over.
    • enforce: 422 con {"error": {"code": "policy_incomplete", "message", "missing": [...]}}. Rechaza el paso a live y 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 recibe warnings.
    • off: sin comprobación y sin warnings.

    Envía las claves en el mismo PATCH o antes, y lee warnings en cada 200 de un agente en vivo. Fuente: core v0.134.0 lib/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 tenant ai_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 ponga policy.take_payments en card_on_file en un agente que no lo guarda ya se rechaza con 422 (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 con card_on_file se puede seguir guardando con él, y la plataforma lo sirve al runtime de voz como none hasta que se encienda el ajuste. Envía none en 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: core lib/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}/archive
  • start persiste running PRIMERO 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_request ante 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_configured ya no se acepta aquí: el schema es cerrado y enviarlo es 400 bad_request. La divulgación de voz artificial de la vía NO-IA se almacena una vez con PUT /v1/artificial-voice-disclosure y se resuelve por intento, en vez de afirmarse en el arranque.

  • pause persiste paused y detiene el runner; las llamadas en curso terminan por el pipeline del CDR.

  • stop persiste completed (terminal) y detiene el runner.

  • archive solo desde draft o completed.

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, o null cuando no hay ninguno. Una ronda que hace una llamada y no termina en un freno lo borra.
  • tenant_throttle_by_reason — tenant_throttled desglosado 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_reasonQué detuvo el marcado
plan_inactiveEl plan de la cuenta no está activo.
spend_cap / account_spend_capSe alcanzó el presupuesto de IA de voz del mes: el de esta cuenta, o el de la empresa sumado entre todas sus cuentas.
wallet_insufficientEl saldo prepagado no cubre una llamada.
plan_exhaustedSe agotó el crédito pospago, o la asignación incluida del plan para el periodo.
exposure_limitSe 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_cpsEl 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_unavailableLa comprobación de capacidad no pudo decidir, así que rechazó.
ai_capacity / ai_capacity_undeclared / ai_capacity_unavailableSolo 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=7
json
{
  "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}/rewind
json
{
  "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_callbacks vale true por defecto; false cancela las rellamadas pendientes de la campaña para las deudas reencoladas.
  • Fija una marca de agua requeued_at por deuda y resucita closed → open. Las deudas en paid / settled / disputed nunca se reencolan.
  • amd_verdicts acepta unspecified | human | machine | not_sure; un valor desconocido devuelve 422 unprocessable (fail-closed, nunca se ignora en silencio). Los tipos de filtro erróneos devuelven 400.
  • En lugar de filtros sueltos puedes enviar {"segment_id": "..."}: un segmento guardado. Lo aceptan tanto la vista previa (cuerpo de POST y GET ?segment_id=) como la llamada de ejecución. Mezclar segment_id con cualquier filtro es 400; un segmento desconocido, retirado o de otro tenant es 404.

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-segments
json
{
  "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:

  • filters pasa por el mismo validador que el propio rewind: un conjunto de filtros que el rewind rechazaría es 422 unprocessable al guardar, y una clave no reconocida es 422 nombrándola. Un segmento cuyos filtros guardados dejan de validar responde 422 al usarse; nunca un rewind más amplio.
  • name se recorta, de 1 a 80 caracteres, y es único entre los segmentos vigentes del tenant sin distinguir mayúsculas (409 conflict en caso contrario). Un segmento retirado libera su nombre.
  • GET /v1/rewind-segments lista los segmentos vigentes. PATCH /v1/rewind-segments/{id} renombra y/o reemplaza filters (sin fusionar); una edición que no cambia nada no escribe nada.
  • POST /v1/rewind-segments/{id}/retire es 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-mappings con name (único entre las plantillas vigentes del tenant, 409 en caso contrario), mapping (una lista ordenada de {column, field}; al menos una entrada debe mapear a algo distinto de skip) y options opcional (separator: comma | semicolon | tab; default_currency: tres letras mayúsculas). El servidor comprueba la forma de field, 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. PATCH con un mapping u options distintos 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ía base_version para que se rechace la edición (409) si alguien editó antes.
  • Archivar, nunca borrar. DELETE archiva (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> en POST /v1/debts/import o en POST /v1/debt-lists/{id}/import; la importación registra qué versión usó y cuántos elementos entraron y fallaron, legible en GET /v1/import-mappings/{id}/uses. En la subida multipart, mapping_id sustituye a las partes mapping y separator: 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_e164 debe 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 lista GET /v1/debts. La subida CSV multipart lo toma como el destino debtor_name del mapping y lo valida por fila igual que la importación JSON. Una importación es una instantánea del elemento: volver a importar una deuda (mismo external_ref) sin debtor_name borra el nombre que tenía, como todos los demás campos del elemento. Publicado en core v0.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_window en 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 que last_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 de conversations con source: cms_import y adelanta el ancla last_conversation_at de 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 core v0.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_at es el instante del reloj de cumplimiento en el que se evaluó cada cifra.
  • La ventana es un veredicto de las comprobaciones reales. evaluated_gates lista las reglas dependientes del tiempo que hay detrás de window y next_window; not_evaluated_gates lista lo que no cubren, de modo que permitted_now no es una promesa de que una campaña vaya a marcar el número. status es permitted_now, scheduled (el primer instante permitido en at, 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) o undecidable (la ficha no puede decidir y da el reason; nunca se lee como permiso).
  • El nombre del deudor. Cada deuda lleva debtor_name: el nombre que la importación envió para esa cuenta, o null. Se descifra solo para esta respuesta; la lista GET /v1/debts no 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 en GET /v1/debts.
  • Errores. 400 bad_request para un id en blanco, de más de 128 caracteres o con un byte NUL; 404 not_found para un consumidor sin deudas en tu tenant, que es también lo que recibes por un consumer_ref que 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}/members lee los miembros, del que entró antes al último, en páginas de hasta 500 (?limit=, un valor mayor se recorta) con un ?cursor= opaco (el next_cursor de la página anterior; null en la última). Cada fila trae debt_id, el external_ref del cliente y added_at: ni teléfono ni nombre del deudor. Una lista archivada se sigue pudiendo leer; 404 si la lista no es tuya.
  • DELETE /v1/debt-lists/{id}/members saca hasta 500 deudas en una llamada, con el cuerpo {"debt_ids": ["…"]} (un cuerpo en un DELETE) y responde 200 {"removed": N}, la cuenta de las que de verdad salieron. 400 si debt_ids está 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ámetroTipoNotas
campaign_id, debt_id, agent_idUUIDFiltros. Valores que no son UUID → 400.
dispositionstringCoincidencia 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, toRFC 3339Límites sobre started_at.
limitintPor defecto 100, máximo 1000.
cursorstringCursor 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_snapshot lleva 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ónCuándo
answered_machineLa detección de contestadora dijo máquina, sea cual sea el tipo de campaña.
ai_conversationUna 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.
deliveredUna campaña TTS, IVR o de encuesta: se entregó el mensaje grabado o el menú.
abandonedSolo cuando se esperaba a un agente y ninguno tomó la llamada.
  • Solo hacia adelante. Las filas escritas antes de v0.73.0 conservan abandoned. Un informe, filtro o exportación que buscaba abandoned para encontrar mensajes entregados o conversaciones de IA debe buscar los códigos nuevos desde esa versión, y conservar abandoned para las filas más antiguas.
  • Tus reglas de reintento vinieron con ello. La actualización copió cada regla que habías escrito para abandoned a delivered, ai_conversation y answered_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 de answered_machine aplicarse a las máquinas alcanzadas por el marcador predictivo, que antes caían bajo abandoned.
  • La puntuación de contacto no cambia: los códigos nuevos puntúan exactamente igual que el abandoned al 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 eso nextCursor está ausente en la última página, donde /v1/call_attempts responde next_cursor: null.
  • Nada más allá de la consulta del CDR. El registro lleva solo lo que /v1/call_attempts ya 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 de disposition y viaja en labels.disposition_code. El filtro disposition sigue 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:

  1. Pide con limit (≤ 1000). La respuesta incluye next_cursor cuando la página venía llena, y null cuando llegaste al final.
  2. Pasa cursor=<next_cursor> (tal cual, es opaco) con los mismos filtros para obtener la página siguiente.
  3. Repite hasta que next_cursor sea null.
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 main de core (core#1587, ADR #211), después de core v0.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:

HTTPerror.codeAcción del cliente
422disclosure_attestation_requiredRecoge 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.
409attempt_not_finalLa 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:

  1. Lee GET /v1/admin/tenants/{id}/config y localiza disclosure_attestation_enforced; confirma que se respeta el override.
  2. Con la credencial de administración autorizada por el founder, envía PUT /v1/admin/tenants/{id}/config/disclosure_attestation_enforced con { "value": true }.
  3. Para levantar el requisito, envía el mismo PUT con { "value": false }, o haz DELETE del override para volver al valor por defecto del catálogo (false). Ninguna de las dos operaciones cambia los eventos de divulgación existentes.
  4. Lee GET /v1/admin/tenants/{id}/config-audit, de la más reciente a la más antigua, y selecciona las filas cuya key sea disclosure_attestation_enforced. Para un apagado, lee old_value, new_value, active, actor, outcome y changed_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íasResultado
"disposition": "promise_to_pay" con una carga promise válida201: 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 promise422 unprocessable: una PTP sin términos no es una PTP.
Cualquier otra disposición con una carga promise422 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_phone debe ser ya un contacto de la deuda: las compuertas de cumplimiento necesitan sus datos de línea y de localidad → 422 unprocessable en caso contrario.
  • scheduled_at debe estar al menos 10 minutos en el futuro → 422 en caso contrario; si no es RFC 3339 → 400.
  • Deuda desconocida → 404. campaign_id toma 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 Y scheduled_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. 404 si la extensión es desconocida o está inactiva; 409 conflict si el asiento está en una llamada. El device_mode opcional (browser | external) persiste cómo se puentea este asiento: a los asientos browser se les busca primero en el softphone WSS, y los external (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 available puede salir (409 en 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 sus ddw_ 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_reached o 422 unpriceable_tier.
  • 409 last_admin_seat si es el único asiento admin activo y la edición lo degradaría o lo daría de baja; 409 seat_linked_to_user si está ligado a un usuario de la consola y la edición cambia su correo; 409 conflict si 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 — available o wrap_up → paused. reason es obligatorio y debe ser uno de manual | break | lunch | training | meeting; cualquier otro (o un reason ausente) → 400 bad_request.
  • pause con on_release: true — { "reason": "lunch", "on_release": true } sobre un asiento en ringing o on_call arma la pausa en vez de rechazar con 409: el asiento sigue en su llamada, la respuesta trae pause_armed: true y, cuando la llamada se libera, el asiento pasa directo a paused con ese motivo, nunca por available. Armar de nuevo es idempotente (gana el último motivo) y resume la desarma. Sobre un asiento sin llamada se comporta como la pausa normal. La lectura del asiento trae pause_reason (por qué está en pausa; null si no lo está), state_since (desde cuándo está en su presencia actual) y pause_armed. La consola del agente del dashboard ofrece su botón Pausar al terminar la llamada solo si la lectura del asiento trae pause_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 tag v0.129.0: aún no está en un release con tag, así que la Referencia de la API vendorizada no lista /v1/agents/handle-time ni transfer_chain. Fuente: openapi/dialer-v1.yaml y changelog.d/core-2403.md en core main.

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_chain en GET /v1/calls/{call_id}. La llamada trae transfer_chain, los tramos en orden, [] si nadie la transfirió. Una cadena que no se puede leer es 503, nunca []. Cada tramo tiene seq (desde 1), role, agent_id, destination, via, started_at, ended_at y duration_ms.
    • role es control (el agente tiene la llamada) o consult (el agente está en la pierna de consulta de una transferencia asistida).
    • agent_id es null cuando 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.
    • via es origin para el agente con quien empezó la llamada; si no, el verbo que abrió el tramo (blind_transfer, attended_transfer_complete, attended_transfer_start, ...). destination es la extensión o el número al que apuntó ese verbo, vacío en el tramo de origen.
    • started_at, ended_at y duration_ms son null mientras el tramo sigue abierto o nunca se contestó: no se inventan.
  • GET /v1/agents/handle-time. Recibe from (inclusivo) y to (exclusivo) en RFC 3339, y un agent_id opcional; la ventana cuenta las llamadas que empezaron en ella, abarca como máximo 31 días (400 si no) y 10 000 llamadas (422: se rechaza, nunca se trunca). Solo credenciales de gestión (dd_ o ddu_); un token de agente ddw_ recibe 403. Por agente responde calls, control_ms (tiempo con la llamada en mano), consult_ms (tiempo en una pierna de consulta), occupied_ms (su suma) y aht_ms (control_ms / calls, null si 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 en excluded.open_legs; el control que pasó a un número externo no acredita a ningún agente. definition es "v1" y nunca cambia de significado. 503 si no se puede leer la evidencia.
  • GET /v1/agents/occupancy no cambia. El asiento de origen sigue on_call hasta que termina la llamada, aunque ya la haya transferido; para saber quién la tuvo y cuánto, usa handle-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 misma POST /v1/carriers, cubierta por la Referencia de la API generada; aquí se describen scope: "empresa" y source_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" es 422. Desde esta ruta solo se acepta kind: "byoc": un troncal house es 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_ips es 422. 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_proxy que no es un destino público sip:/sips: es 422. 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_proxy ya usado por otra cuenta es 409 con código trunk_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}/provision aplica la misma comprobación (422 o 409) 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/carriers acepta el campo al crear, GET lo devuelve, y PATCH /v1/carriers/{id} reemplaza la lista entera: [] o null la levanta. allowed_destinations es la única clave que acepta PATCH. Hasta 100 números, E.164 estricto (+ y dígitos), sin duplicados; la lista se guarda cifrada. Solo un troncal byoc admite 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 (403 en caso contrario), igual que crearlo. Un troncal retirado es 409; el troncal de otra empresa es 404.
  • 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óndeQué recibes
Marcado de campañaEl 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 campo srtp de POST /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.

srtpQué hace
off (por defecto)RTP en claro, exactamente como antes de que existiera el campo. Omitir srtp equivale a off.
offerLa plataforma ofrece SRTP y acepta RTP en claro si tu proveedor contesta sin él.
requireLa 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. offer y require exigen un sip_proxy TLS: sips: o ;transport=tls. Si no, la petición es 400 invalid con el mensaje srtp offer and require need a TLS sip_proxy.
  • No escribas srtp= en el proxy. La plataforma compone ese parámetro ella misma; un sip_proxy que ya lo lleva es 400 invalid con el mensaje sip_proxy must not carry an srtp parameter; use the srtp field. Un valor fuera de off, offer y require también es 400 invalid.
  • Cambiarlo. srtp es un valor aprovisionado, como sip_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}/provision escribe el proxy como <sip_proxy>;srtp=<modo> solo para offer y require; con off escribe 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 main de 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:

CifraDónde rigeRespaldada por (core main)
100 canales simultáneosUna marcación BYOC de una cuenta cuya fila no declara max_concurrent_channelsbyoc_assumed_channels, @default_byoc_assumed_channels en lib/dialer/dialer/capacity.ex
20 llamadas por segundoUna marcación BYOC de una cuenta cuya fila no declara max_cpsbyoc_assumed_cps, @default_byoc_assumed_cps en lib/dialer/dialer/capacity.ex
20 llamadas simultáneasUna 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 segundosEl corte por defecto de una llamada entrante contestada que no atiende la IAinbound_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: 500 la 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/capacity no 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) o tenant_cps (la 21 en el mismo segundo). Son los mismos motivos de la tabla del freno de toda la cuenta: la campaña sigue running y vuelve a marcar cuando hay hueco.
  • IA de voz por BYOC. Las marcaciones con ai_used por un troncal byoc pasan 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_channels o capacity_unavailable si 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_insufficient en prepago, plan_exhausted en suscripción o pospago). Una entrante rechazada se registra con disposición refused_<motivo> y causa de colgado CALL_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}/health devuelve el último estado de salud, con carrier_id, state, reason, since, observed_at, fresh y las últimas signals de sonda/CDR.
  • GET /v1/carriers/{id}/health/events devuelve el historial de transiciones, la más reciente primero, con paginación keyset (limit y cursor). Por defecto excluye las filas de confirmación de cinco minutos; añade include_confirm=true para 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. timezone es 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). weekly asigna mon…sun a {start, end}; un día sin entrada está cerrado, start debe ser anterior a end y una ventana no cruza la medianoche. Exactamente esas dos claves; cualquier otra se rechaza.
  • Leer y quitar. GET /v1/tenant/calling-window responde {"calling_window": null} cuando la cuenta no tiene ninguno, o la ventana guardada con sus horas normalizadas a HH:MM:SS. DELETE la quita y responde {"calling_window": null}. Un valor guardado que no se puede leer es 503, nunca «sin ventana».
  • Quién puede escribir. Una clave de API dd_, o un usuario del dashboard con el rol account_admin; cualquier otro inicio de sesión del dashboard puede leer pero recibe 403 role_forbidden en PUT/DELETE. Cada escritura se audita con su autor.
  • Falla en modo cerrado. Un cuerpo mal formado es 422 unprocessable con 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óndeQué recibes
Marcado de campañaEl 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 tag v0.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_typeQué nombraQué recibe una llamada
agentdest_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.
ivrdest_ref = el id de un menú publicado, no retirado, de esta cuenta (obligatorio).Ejecuta ese menú (ver abajo).
queuequeue_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).
optoutNada. 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íasLo 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 active422 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_type400 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 cuenta400 invalid en dest_ref.
queue_id que no es una cola activa de esta cuenta, o en un tipo que no es queue400 invalid.
optout con un dest_ref, recording: true o ai_enabled: true400 invalid.
ai_enabled: true en cualquier ruta400 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 nunca optout: 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ónLo 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 audio503 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 cuenta404
Publicar con el menu_id de un menú retirado409 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_admin403 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ónQué 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-queuesLas 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}/overflowUna llamada que ha esperado overflow_max_wait_ms pasa a overflow_queue_id. Ambos campos son obligatorios.
DELETE /v1/inbound-queues/{id}/overflowQuita el desbordamiento: una llamada espera todo lo que haga falta.
POST /v1/inbound-queues/{id}/retireRetira 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/liveCuántas llamadas esperan en cada cola activa y desde hace cuánto: ver la lectura en vivo.
GET /v1/inbound-queues/{id}/liveLa 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ónLo 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 esta400 invalid. Un ciclo de desbordamiento se rechaza.
Desbordar, retirar o leer una cola que no es una cola activa de esta cuenta404 (leer una responde también las retiradas)
Retirar mientras algo activo nombra la cola409 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_admin403 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.

  • depth es 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_ms es cuánto lleva esperando la llamada que más espera, leído del reloj en as_of. Es null cuando 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_enabled está apagado para la cuenta, nada escribe el historial de la cola, así que la respuesta es stream_enabled: false con depth: null y oldest_wait_ms: null, nunca 0. ready_agents sigue la misma regla con agent_session_journal_enabled: null cuando ese interruptor está apagado. Muestra «las colas están apagadas», no una cola vacía.
  • wait_bound_ms es 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 el overflow_max_wait_ms de la cola cuando tiene desbordamiento, o una hora cuando no lo tiene.
  • ready_agents es 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 por e164; un status erróneo → 400).

  • POST /v1/dids → 201 {"did": {...}}. Campos: e164 (obligatorio, E.164, único por tenant → 409 ante duplicados), npa (derivado de los números +1 si se omite), us_state, status (por defecto active), labels (arreglo de cadenas), notes. El tenant no escribe attestation (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 (schema DID en core:openapi/dialer-v1.yaml).

  • GET /v1/dids/{id} → un DID.

  • Quién puede escribir. POST, PATCH y DELETE /v1/dids exigen una clave dd_ o un inicio de sesión account_admin; un inicio de sesión member recibe 403 role_forbidden. Los GET siguen abiertos. Ver Quién puede escribir la telefonía.

  • PATCH /v1/dids/{id} — solo metadatos/estado; e164 es inmutable (un número distinto es un DID distinto; el historial del CDR nunca se reatribuye).

  • DELETE /v1/dids/{id} — retira (status retired), idempotente. Las filas nunca salen del registro: la procedencia del from_number del CDR debe seguir resolviéndose para el paquete de defensa.

  • GET /v1/dids/{id}/health?from&to (RFC 3339 sobre started_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 son null cuando el denominador es cero (nunca se inventan); reputation.* son null explí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:

RecursoEscrituras
NúmerosPOST, PATCH, DELETE /v1/dids
Rutas de entradaPOST, PATCH /v1/inbound-routes, …/{id}/provision, …/{id}/disable
Troncales de sedePOST, PATCH /v1/carriers, …/{id}/provision, …/{id}/revoke (un troncal de toda la empresa ya exigía solo account_admin)
Sitios del widgetPOST /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:

ValorUn número customer sin verificar…
offse usa como antes; no se comprueba nada.
observe (por defecto)se sigue usando; la plataforma sólo cuenta el uso.
enforcese 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/messages con un from (o un remitente por defecto del proveedor) que es un número customer nunca verificado → 422 did_unverified.
  • send_unavailable_reason en GET /v1/campaigns/{id}/stats (y el frame campaign.stats) vale did_unverified cuando los únicos números activos desde los que enviaría la campaña están registrados pero sin verified_at. Pide a soporte que los verifique. (Fuente: cuerpo de sendSms y el enum de send_unavailable_reason en core: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_30d se calcula sobre las promesas ya resueltas —cumplidas o incumplidas— con promised_date en los últimos 30 días; null significa 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_human o 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_rate es contacts / attempts, y null para 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, o no_zone cuando no se registró ninguna zona) y nunca se reparte entre las horas.
  • best_window es 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 menos min_attempts intentos (el ajuste del tenant contact_window_min_attempts, por defecto 50). Los empates van a la hora más temprana. Sin datos suficientes es null y best_window_reason dice por qué —insufficient_attempts o no_resolved_attempts— en lugar de elegir una hora a partir de ruido.
  • phones_per_person es el número medio de teléfonos distintos por persona entre las cuentas activas (no paid, settled ni closed); una persona sin teléfono cuenta como cero. Cubre todo el tenant e ignora from, to y campaign_id. average es null cuando 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.0 y 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_mode es COMPLIANCE o GOVERNANCE; version_id identifica la versión almacenada del objeto y retain_until es la hora que confirmó el almacén. Son hechos devueltos por el almacén; la API no afirma que GOVERNANCE tenga la semántica de COMPLIANCE. (Campos: las propiedades version_id, retention_mode y retain_until del schema DefensePacketArchive en core:openapi/dialer-v1.yaml.)
  • Retención por regla del bucket: retention_mode es bucket_lock. En ese caso version_id y retain_until son null, y manda la regla del bucket. Llámala retención configurada, nunca COMPLIANCE ni retención inmutable. (Campos: las propiedades version_id, retention_mode y retain_until del schema DefensePacketArchive en core: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.)

  1. Obtén el kit y descarga de inmediato el ZIP mientras la URL es válida.

    bash
    BASE_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.zip
  2. Compara 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_sha256 e integrity_digest del schema DefensePacketArchive en core:openapi/dialer-v1.yaml.)

    bash
    expected="$(jq -er '.attestation.artifact_sha256' kit.json)"
    actual="$(sha256sum artifact.zip | awk '{print $1}')"
    test "${actual}" = "${expected}"
  3. Verifica la respuesta DER contra ese mismo digest y la cadena de confianza de la TSA.

    bash
    openssl ts -verify \
      -digest "${actual}" \
      -in artifact.tsr \
      -CAfile tsa-root.pem \
      -untrusted tsa-intermediates.pem

    Si 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 como attestation.tsa_gen_time. (Contrato de verificación y campo de hora: la descripción de la operación getDefensePacketAttestation y la propiedad tsa_gen_time del schema DefensePacketArchive.)

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.

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.

RutaQué 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/importRegistra muchos números desde un cuerpo text/csv.
  • Quién puede escribir. Una llave dd_ o un login del dashboard con rol account_admin. Un login member recibe 403 role_forbidden en las tres rutas y no se guarda nada; un token de widget o un tenant suspendido recibe 403 forbidden.
  • Qué lleva una escritura. channel (voice, sms o any), consented_at (ISO 8601 con desfase), source (hasta 200 caracteres: dónde se obtuvo el consentimiento) y evidence_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. Sin evidence_ref el 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 %2B o 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_at no 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 si consented_at es posterior a la revocación; si no, 409 consent_revoked. Si la revocación no lleva fecha, sólo la levanta el consumidor, con START. 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 en rejected (rejected_count lleva el total real). Otro tipo de contenido es 415. Cada fila se valida como la escritura individual. La respuesta es {received, recorded, rejected_count, rejected: [{line, value, reason}]} con el valor enmascarado: un 200 no significa que se registraron todas las filas; manda rejected_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 como sms abre también la voz, igual que ya lo hace START.
  • La prueba mínima que exige la plataforma y el trato de un landline que declaras al importar siguen sin decidirse; hoy un landline declarado 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_ref ni text_version por 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_enabled está 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: null con ineligible_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_copies informa mutated, nothing_to_mutate o not_configured). Si esa copia no se puede cambiar, la petición falla con 503 erasure_failed y 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ónAjusteSi está apagado
Simulacroprivacy_erasure_enabled (por cuenta)403 privacy_erasure_disabled, no se escribe nada
dry_run: falseprivacy_erasure_execute_enabled403 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_enabled ENCENDIDA pueden leerlo (un operador de la plataforma escribe la bandera). Con ella APAGADA, todo el que llama, con cualquier rol, recibe el mismo 404 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 rol account_admin. Cualquier otro inicio de sesión recibe 403 role_forbidden, con el mismo cuerpo envíe lo que envíe.
  • Ventana. from y to son instantes RFC 3339 obligatorios, from < to, con como máximo 31 días de distancia (400 bad_request en 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_days dice cuáles).
  • No se descarta nada. Cada fila del libro cae en exactamente un grupo, o en el cubo con key: null y unattributed: true. Las monedas nunca se mezclan: cada grupo y cada total lleva la suya. billed_by es platform (troncal propio de la plataforma), tenant_carrier (el operador telefónico propio del cliente) o null.
  • 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.

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