Skip to content

Webhooks de salida (integración con CRM) ​

English version: Outbound webhooks.

Los webhooks de salida empujan hechos de la plataforma a tu servidor: cuando termina una llamada, cuando un agente registra una promesa de pago, cuando un consumidor responde por SMS o se da de baja, cuando una grabación pasa a poder descargarse. Registras un endpoint HTTPS, lo suscribes a los eventos que te interesan y la plataforma le hace un POST con un pequeño sobre JSON firmado — sin sondeo y sin un socket de larga vida.

Disponibilidad: publicado en core desde la v0.36.0

Esta página documenta el contrato de MT-CTI-04, fases 1–4 (ADR #121, #122 y #123 con su adenda de la fase 4), publicado en core v0.36.0. La superficie /v1/webhooks está en la Referencia de la API, que manda sobre las formas de petición y respuesta; esta página sigue siendo la guía de integración. Confirma con tu operador que la versión de tu entorno es al menos la v0.36.0.

No es lo mismo que los webhooks del proveedor de SMS

Los Webhooks de la Referencia de la API son endpoints que alojamos nosotros y que llama tu proveedor de SMS (acuses de entrega, respuestas de consumidores). Estos van en sentido contrario: te llamamos nosotros. Los dos usan esquemas de firma distintos, así que no reutilices un verificador entre ellos: el del SMS entrante es un resumen hexadecimal desnudo en x-dd-signature; el de abajo es un valor con marca de tiempo t=…,v1=….

La forma de una integración ​

1. POST /v1/webhooks            → endpoint registered, signing secret shown ONCE
2. … a call ends, a promise is recorded, an SMS arrives …
3. POST https://crm.example.com/hooks/dialerdigital
      X-DD-Event: call.ended
      X-DD-Delivery-Id: <uuid>          ← your idempotency key
      X-DD-Signature: t=…,v1=…          ← verify this before parsing
      {"event":"call.ended","event_id":"…","occurred_at":"…","tenant_id":"…","data":{…}}
4. you answer 2xx  → delivered
   anything else   → retried on a capped ladder, then dead-lettered
   410 Gone        → the endpoint is disabled and nothing else is sent

Paso 1 — registra un endpoint ​

bash
curl -s -X POST $API/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://crm.example.com/hooks/dialerdigital",
    "events": ["call.ended", "promise.recorded"]
  }'
json
{
  "webhook": {
    "id": "…",
    "url": "https://crm.example.com/hooks/dialerdigital",
    "events": ["call.ended", "promise.recorded"],
    "enabled": true,
    "disabled_reason": null,
    "secret_hint": "…",
    "previous_secret_expires_at": null
  },
  "secret": "whsec_…"
}

secret es la clave HMAC de firma y se muestra una sola vez, aquí. No vuelve a poder leerse: cada lectura posterior muestra secret_hint, los últimos 4 caracteres del secreto vigente — suficiente para saber qué clave tienes, inútil para falsificar una firma. Guárdalo en tu gestor de secretos antes de cerrar la respuesta.

Lo que debe ser la url, o la llamada da 400 nombrando url: https:// en el puerto 443, con un host que resuelva únicamente a direcciones públicas. Se rechazan, con cierre por defecto, una IP literal, una dirección de loopback, RFC1918, CGNAT, de enlace local o de metadatos de la nube, un nombre .internal o de clúster, y un nombre que no resuelva en absoluto — y la misma comprobación se repite en el momento de la entrega, porque el DNS es mutable y un nombre que ayer resolvía a una dirección pública hoy puede apuntar a una de metadatos.

events es un subconjunto no vacío y sin duplicados del catálogo cerrado v1 de más abajo; un nombre desconocido o repetido da 400. tenant_id y secret no son campos de la petición — la clave bearer es el tenant, y un cuerpo que lleve cualquiera de los dos da 400 nombrándolo.

Los endpoints se gestionan con el resto de la superficie: GET /v1/webhooks, GET /v1/webhooks/{id}, PATCH /v1/webhooks/{id} (url / events / enabled) y DELETE /v1/webhooks/{id}, que no borra la fila: pone enabled: false con disabled_reason: "disabled_by_api" y conserva el registro de a qué estaba suscrito y desde cuándo. PATCH {"enabled": true} lo reactiva.

Tope: 20 endpoints habilitados por tenant. Cada evento escribe una fila de entrega por endpoint, así que el registro está acotado. Sólo cuentan los habilitados; los deshabilitados no. POST /v1/webhooks — y PATCH /v1/webhooks/{id} con {"enabled": true}, que es una creación disfrazada — responden 422 unprocessable («the tenant has too many enabled webhook endpoints») cuando el tenant ya tiene 20 habilitados. Para liberar un hueco, deshabilita uno (DELETE) y reintenta. Un tenant que ya está por encima del tope conserva todos sus endpoints y siguen recibiendo; sólo se rechaza uno nuevo o rehabilitado. Puedes registrar la misma URL más de una vez: no se exige unicidad. El valor por defecto (20) vive en lib/dialer/webhooks.ex de core (default_max_endpoints/0); la llave de catálogo por tenant webhook_max_endpoints lo sustituye, y la fijamos nosotros, no se cambia por la API.

Paso 2 — el sobre v1 ​

Todo cuerpo de entrega es el mismo sobre de cinco campos, serializado en el instante del hecho, dentro de la transacción que lo persistió, y nunca reconstruido después:

json
{
  "event": "promise.recorded",
  "event_id": "019968d2-6b1e-7c4a-9b0e-3f2a1c5d7e90",
  "occurred_at": "2026-09-17T20:15:30.123456Z",
  "tenant_id": "5a1c3e7d-2f4b-4c8e-9d1a-0b2c3d4e5f60",
  "data": { "…": "the public /v1 projection of the resource" }
}
CampoQué es
eventUn nombre del catálogo v1. También se repite en la cabecera X-DD-Event, para que puedas enrutar sin parsear.
event_idEl id de la fila que anuncia el evento. Único por endpoint — mira la idempotencia.
occurred_atCuándo ocurrió el hecho (el ended_at de la llamada, el occurred_at del mensaje, …), no cuándo se envió. Una reentrega dos horas después lleva el mismo occurred_at.
tenant_idTu tenant. Útil cuando un mismo receptor atiende varios espacios de trabajo.
dataLa proyección pública del recurso — byte por byte la forma que responde el GET de ese mismo recurso, de modo que un receptor que ya parsea respuestas de /v1 reutiliza su parser.

El catálogo de eventos v1 ​

El catálogo está cerrado: son estos ocho nombres. Una suscripción a cualquier otro se rechaza por partida doble: por la API a la entrada y por una restricción CHECK de la propia columna events.

Eventodataevent_id es
call.endedCallAttempt — la fila de corrección de finalizaciónel id de la fila de corrección
call.answeredCallAttempt — la fila original, con answered_at<original attempt id>:answered (una respuesta no tiene fila durable propia)
promise.recordedPromiseel id de la promesa
sms.receivedSmsMessage (proyección de lista, body_preview de 40 caracteres)el id de la fila del libro mayor
sms.optoutSmsMessage (la misma)el id de la fila del libro mayor
recording.availableRecordingel id de la grabación
disposition.event{ "name", "disposition", "debt_id", "campaign_id" } — el efecto con nombre de una regla de disposiciónun resumen SHA-256 en minúsculas, determinista, del tenant, la deuda, la disposición, el ancla del intento y el nombre del evento
carrier.health_changedTrunkHealthEvent — la fila del evento de salud (una transición de estado de una troncal BYOC)el id del evento de salud

Cada uno tiene un productor transaccional, de modo que el anuncio se confirma — o se revierte — junto con el hecho. No existe una ventana de «escribimos la fila pero olvidamos avisarte», y no se emite ningún evento por un hecho que se revirtió.

disposition.event se emite sólo cuando una regla de disposición aplicada incluye un efecto emit_event. Su objeto data es exactamente:

json
{
  "name": "the event name configured by the rule",
  "disposition": "the disposition code that triggered the rule",
  "debt_id": "the debt UUID",
  "campaign_id": "the campaign UUID or null"
}

El resumen de event_id es la clave de idempotencia de este hecho. Persístelo antes de hacer el trabajo posterior: una repetición de la misma disposición deriva la misma clave, y el outbox de webhooks anuncia esa clave como máximo una vez a cada endpoint.

Publicado en core v0.87.0 (core#1505), y en la Referencia de la API.

carrier.health_changed anuncia una transición de estado de una troncal BYOC (ADR #196): state pasa a up, down o unknown, y prev_state es el estado que dejó (null en el primer evento de la troncal). Sólo se anuncian transiciones — la fila confirm de cinco minutos es durable y se lee con GET /v1/carriers/{id}/health/events, pero nunca llega a tu endpoint. Como el anuncio se escribe en la misma transacción que el evento de salud, una transición que se revierte no anuncia nada.

data es la proyección TrunkHealthEvent — el mismo objeto que devuelve GET /v1/carriers/{id}/health/events, de modo que un receptor que ya parsea esa página reutiliza su parser. Este es el sobre completo:

json
{
  "event": "carrier.health_changed",
  "event_id": "0199a3f0-6c2b-7d51-8f0a-2b4c6d8e0f13",
  "occurred_at": "2026-10-06T12:00:00.000000Z",
  "tenant_id": "5a1c3e7d-2f4b-4c8e-9d1a-0b2c3d4e5f60",
  "data": {
    "id": "0199a3f0-6c2b-7d51-8f0a-2b4c6d8e0f13",
    "carrier_id": "2f3a4b5c-6d7e-4f80-91a2-b3c4d5e6f708",
    "state": "down",
    "prev_state": "up",
    "reason": "probe_inactive",
    "evidence": { "probe": { "replicas": { "up": 0, "down": 2, "fresh": 2 }, "latency_ms": null }, "cdr": null, "consecutive": 2 },
    "observed_at": "2026-10-06T12:00:00.000000Z"
  }
}

event_id es el id de la fila del evento de salud — el mismo valor que data.id —, así que una reentrega lo repite y una repetición de la misma transición no puede anunciarla dos veces. Deduplica con él igual que con cualquier otro evento de aquí. reason es la etiqueta del evaluador: en un evento es una de probe_inactive, probe_active, cdr_failure_ratio, cdr_success_ratio, no_fresh_samples o inconclusive (la razón propia de la fila confirm nunca viaja aquí). evidence es lo que vio — probe.replicas (up / down / fresh), probe.latency_ms (null salvo que las réplicas publiquen ds_ping_latency_stats), cdr (null cuando la ventana no trae conteos) y consecutive, el contador de antirrebote — y occurred_at es el observed_at de la transición. Una troncal de una sede notifica a esa sede; una troncal de toda la empresa notifica a las sedes donde es la efectiva.

La sonda que hay detrás de estos eventos la controla el flag de toda la plataforma byoc_trunk_health_probe_enabled, apagado por defecto: con él apagado no se escribe ningún evento de salud y, por tanto, no se anuncia nada.

Publicado en core v0.89.0 (core#1615). La Referencia de la API de este sitio se genera a partir de una copia del contrato que se refresca versión a versión, así que el evento aparece allí cuando esa copia llegue a v0.89.0; hasta entonces la guía de arriba es el contrato de integración.

Paso 3 — verifica la firma ​

Toda entrega lleva:

X-DD-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 over "<t>.<raw body>">

La marca de tiempo va dentro de la cadena firmada, así que una petición capturada no puede reproducirse más tarde con un t nuevo. Verifica en este orden, y hazlo todo antes de parsear el JSON:

  1. Extrae t y v1 de la cabecera. Cualquier cosa que no se pueda parsear es un rechazo, no un aviso.
  2. Compara t con tu reloj con una tolerancia — la implementación de referencia de la propia plataforma usa 5 minutos, en cualquier dirección, para que un reloj del receptor ligeramente adelantado no lo rechace todo.
  3. Recalcula HMAC-SHA256(secret, "<t>." + raw_body) sobre los bytes crudos exactamente como llegaron. Reserializar el JSON — orden de claves, espacios, formato de números — cambia los bytes y rompe el MAC.
  4. Compara en tiempo constante. Una comparación byte a byte que sale en cuanto difiere filtra el resumen esperado a quien pueda medir los tiempos de tus respuestas.

Node.js ​

js
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => {
      const i = p.indexOf('=')
      return i === -1 ? [p, ''] : [p.slice(0, i), p.slice(i + 1)]
    })
  )
  const t = Number.parseInt(parts.t ?? '', 10)
  const provided = parts.v1 ?? ''
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(provided)) return 'malformed'
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return 'stale'

  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex')
  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(provided, 'utf8')
  return a.length === b.length && timingSafeEqual(a, b) ? 'ok' : 'mismatch'
}

rawBody debe ser el cuerpo de la petición como Buffer o cadena, capturado antes de que cualquier middleware de JSON lo tocara. La mayoría de los frameworks de Node parsean el cuerpo por defecto y entregan a tu manejador un objeto, no bytes: busca el gancho de cuerpo crudo que ofrezca el tuyo y comprueba que lo que firmas no sea la recodificación del objeto.

Python ​

python
import hashlib, hmac, re, time

_HEX64 = re.compile(r"^[0-9a-f]{64}$")


def verify(secret: bytes, raw_body: bytes, header: str, tolerance_seconds: int = 300) -> str:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return "malformed"
    if not _HEX64.match(provided):
        return "malformed"
    if abs(int(time.time()) - t) > tolerance_seconds:
        return "stale"

    expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return "ok" if hmac.compare_digest(expected, provided) else "mismatch"

El mismo requisito: raw_body son los bytes de la petición sin tocar, leídos antes de que nada los decodifique.

Los dos devuelven los mismos cuatro resultados que el verificador de la propia plataforma — ok, malformed, stale, mismatch —, de modo que una línea de log en un lado significa lo mismo en el otro. Sólo ok puede llegar a tu lógica de negocio; todo lo demás es una petición rechazada.

Responder ​

Responde 2xx y hazlo rápido: la petición entera dispone de 15 segundos (5 de ellos para conectar) antes de contar como fallo y reintentarse. Verifica en línea, encola el trabajo y devuelve la respuesta — no hagas la escritura en el CRM en el hilo de la petición.

El cuerpo de tu respuesta se lee hasta 64 KiB y el primer 1 KiB se guarda como evidencia (response_excerpt) — nunca tus cabeceras de respuesta. Un acuse son unos pocos bytes; no ganas nada devolviendo más.

Paso 4 — rotar el secreto de firma ​

bash
curl -s -X POST $API/v1/webhooks/$ID/rotate-secret -H "Authorization: Bearer $TOKEN"

La rotación genera un secreto nuevo y lo devuelve una sola vez — la segunda y última vez que se muestra un secreto. El secreto anterior sigue verificando hasta previous_secret_expires_at, que por defecto es 24 horas después de la rotación. Mientras esa ventana está abierta, cada entrega lleva dos cabeceras:

X-DD-Signature:          t=…,v1=…   ← signed with the NEW secret
X-DD-Signature-Previous: t=…,v1=…   ← same construction, PREVIOUS secret

Así, una rotación sin interrupción es: rotar → desplegar el secreto nuevo en tu receptor a tu ritmo → listo. Un receptor que aún tiene la clave vieja verifica X-DD-Signature-Previous; uno que ya cambió verifica X-DD-Signature e ignora la segunda cabecera. Sólo verifican dos secretos a la vez — rotar de nuevo con una ventana abierta reemplaza el anterior, no apila un tercero. Cuando la ventana se cierra, el secreto viejo no vuelve a usarse para firmar.

Si aceptas ambas cabeceras durante una migración, acepta la entrega cuando verifique cualquiera de las dos — y vuelve a aceptar sólo X-DD-Signature cuando hayas cambiado, para que una clave caducada no sea para siempre una segunda credencial válida en tu código.

Paso 5 — reintentos e idempotencia ​

Qué se reintenta ​

Tu respuestaQué ocurre
2xxdelivered. Hecho.
410 GoneEl endpoint queda deshabilitado (disabled_reason: "endpoint_gone_410") y no se le envía nada más. Es la señal deliberada de «para, esta URL está muerta» — reactívalo con PATCH {"enabled": true} cuando la URL vuelva.
cualquier otro estado, un timeout, un error de transporte o de TLS, un cuerpo de respuesta por encima del tope de lecturaUn fallo — se reintenta.

No existe una regla de «sólo se reintentan los 5xx»: un 404 o un 403 de tu lado es un fallo como cualquier otro, porque desde aquí no se distingue de un proxy mal configurado. 410 es el único estado con un significado propio.

La escalera ​

La entrega se intenta hasta 5 veces por defecto. Tras un fallo, el siguiente intento vence 1 m, 5 m, 30 m y 2 h después, respectivamente — la escalera es exponencial y con tope, nunca ilimitada, y es configurable por despliegue (WEBHOOKS_MAX_ATTEMPTS / WEBHOOKS_BACKOFF_SECONDS), así que pide a tu operador los valores vigentes si dimensionas un presupuesto de caída. Cuando falla el último intento, la entrega pasa a dead-letter: deja de reintentarse y se queda en el flujo de evidencia con su motivo de fallo.

Otros dos rechazos ocurren sin ninguna petición, así que no te cuestan nada: un sobre de más de 256 KiB pasa a dead-letter como payload_too_large de inmediato (un reintento no lo hará más pequeño), y una URL cuyo host ya no resuelve a una dirección pública la rechaza la guarda de salida en el momento del envío.

Deduplica — una entrega puede repetirse ​

X-DD-Delivery-Id: <uuid>

La entrega es al menos una vez. Un worker que muere a mitad de lote revierte, y sus entregas se intentan de nuevo; un fallo de red después de que tu servidor confirmara pero antes de que la respuesta nos llegara se reintenta; un operador puede pedir una reentrega a mano. Así, la misma entrega puede llegar más de una vez, y la clave de idempotencia del receptor es X-DD-Delivery-Id.

Hazla una columna única y deja que el conflicto del insert haga el trabajo:

sql
CREATE TABLE dialerdigital_deliveries (
  delivery_id uuid PRIMARY KEY,
  event       text        NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);
-- per delivery, inside the transaction that applies the effect:
INSERT INTO dialerdigital_deliveries (delivery_id, event) VALUES ($1, $2)
ON CONFLICT (delivery_id) DO NOTHING;
-- 0 rows affected ⇒ already applied ⇒ answer 2xx and do nothing else

event_id es la segunda clave, más gruesa: identifica el hecho, es único por endpoint y es estable entre las entregas de ese hecho. Deduplica con X-DD-Delivery-Id para colapsar las repeticiones de una misma entrega; deduplica con event_id si quieres asegurarte de que un hecho se aplica una sola vez incluso tras una re-suscripción. El exactamente-una-vez es tu clave, no nuestra promesa.

Lo que no se promete ​

  • Sin orden. Nada garantiza el orden en que llegan las entregas — ni entre eventos, ni entre endpoints, ni entre dos eventos de la misma llamada. Cada entrega lleva su propia escalera de reintentos, así que un solo intento fallido basta para invertir un par: si un call.answered falla y vuelve a vencer en un minuto, el call.ended encolado después se envía primero. Varios workers despachadores vacían el outbox a la vez, que es la segunda forma de reordenar un par. Ordena tu propia máquina de estados por occurred_at y por los campos de data, nunca por orden de llegada.
  • Al menos una vez, no exactamente una. Mira arriba; deduplica.
  • No es una cola que puedas reproducir a voluntad. El flujo de evidencia es de sólo anexar y legible, y una entrega concreta puede reencolarse a mano, pero no existe un verbo masivo de «reproduce la última hora».
  • El catálogo está cerrado en ocho eventos. Cualquier otra cosa que necesites es un cambio en la plataforma, no una suscripción que hoy puedas expresar.

Evidencia: lo que se envió y lo que volvió ​

bash
curl -s "$API/v1/webhooks/$ID/deliveries?status=deadletter&limit=50" \
  -H "Authorization: Bearer $TOKEN"

GET /v1/webhooks/{id}/deliveries es el registro de sólo anexar de cada entrega a ese endpoint, de la más nueva a la más antigua, paginado por keyset (limit hasta 200, 50 por defecto; sigue next_cursor hasta que sea null). Filtros: event, status y since (una cota inferior inclusiva sobre la creación de la entrega, RFC 3339). Un valor desconocido de event o status da 400 nombrando el conjunto aceptado — nunca una página vacía en silencio que se lea como «no pasó nada».

Cada entrega lleva sus attempts en orden y, de cada intento, el status_code HTTP, el response_excerpt y los instantes en que la petición empezó y terminó. status está derivado de los intentos, no almacenado: pending mientras no se ha hecho ningún intento; después, el resultado del último — retry, delivered, deadletter o endpoint_disabled.

Esta es la respuesta a «nunca lo recibimos». Es de sólo anexar en la propia base de datos, así que nada de lo ya registrado puede editarse — ni por ti, ni por la plataforma.

Pedir un intento más ​

bash
curl -s -X POST "$API/v1/webhooks/$ID/deliveries/$DELIVERY_ID/redeliver" \
  -H "Authorization: Bearer $TOKEN"

202, no 200: esto anexa un intento de reintento que vence ahora y el despachador lo envía en su siguiente tick; el resultado aparece como el siguiente intento en la lista de entregas. La numeración continúa, así que una entrega que ya agotó la escalera recibe exactamente un envío más — un fallo después de eso se registra como deadletter, no como otro retry.

Un endpoint deshabilitado da 409 nombrando por qué se deshabilitó (endpoint_gone_410 o disabled_by_api); reactívalo primero. También da 409 si se anexó otro intento a la vez — dos reentregas compitiendo, o una compitiendo con el despachador. Lee la entrega de vuelta antes de volver a pedir.

Límites de un vistazo ​

Tamaño del sobre≤ 256 KiB (por encima: dead-letter, nunca se envía)
Timeout de conexión5 s
Petición entera15 s
Lectura de la respuesta≤ 64 KiB; el primer 1 KiB se guarda como evidencia
Tolerancia de la firma5 minutos, en cualquier dirección (implementación de referencia)
Ventana de rotación24 h por defecto
Intentos5 por defecto, luego dead-letter
Página de entregaslimit 1–200, 50 por defecto
Endpoints habilitados20 por tenant; 422 al crear o rehabilitar por encima del tope

Alcance de la credencial ​

La superficie de webhooks es de ámbito tenant: una clave de API dd_ o un inicio de sesión del dashboard. Un token de widget ddw_ recibe 403 — la sesión de navegador de un agente no puede leer ni cambiar adónde se empujan los eventos de un espacio de trabajo. Un endpoint de otro tenant se lee como 404, nunca 403: no se filtra su existencia.

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