Technical reference

Webhooks

Documentation for integrators. If you are looking for how to configure Alpire, the admin guide is in Documentation.

← Back to Documentation

Webhooks — referencia para integradores

Ajustes → Integraciones y automatización (/settings/integrations). Das de alta una URL, eliges eventos y recibes un POST JSON firmado cada vez que ocurre algo. Pensado para n8n, Make, Zapier o tu propio endpoint.

Fuente de verdad: crates/vs-core/src/webhooks.rs. Si algo de aquí no cuadra con el código, manda el código.


1. Envelope

Todos los eventos llegan con la misma envoltura:

{
  "id": "evt_01J8Z9K2QX7M3B4N5P6R7S8T9V",
  "type": "call.ended",
  "created_at": "2026-07-19T04:12:33.481+00:00",
  "org_id": "01J8...",
  "data": { }
}
CampoDescripción
idId del evento, prefijo evt_ + ULID. Estable entre reintentos.
typeTipo de evento (tabla de abajo).
created_atRFC 3339 UTC, momento en que se generó el evento.
org_idTu organización.
dataCuerpo específico del evento.

Los campos son estables; pueden aparecer campos nuevos dentro de data. Ignora los que no conozcas en vez de fallar.

2. Eventos

typeCuándo
call.startedLa llamada es atendida (answered_at). Detectado por barrido cada 15 s.
call.endedLa llamada termina (ended_at). Barrido cada 15 s.
call.transferredTransferencia efectiva entre patas.
transcript.readyLa transcripción post-llamada pasa a success. Barrido cada 15 s.
appointment.bookedCita creada: por voz, desde el panel de Agenda o por POST /v1/appointments.
appointment.cancelledCita anulada: por voz, desde el panel o por POST /v1/appointments/{id}/cancel. Solo agenda nativa.
callback.createdSe programa una devolución de llamada.
campaign.finishedUna campaña de marcación termina.
campaign.contact_completedTermina la conversación de una campaña con agente IA con un contacto, con su resultado y los datos recogidos.
qa.scoredEl análisis de calidad puntúa una llamada.
chat.startedEmpieza una conversación de chatbot: el primer mensaje en el widget, pedir una persona sin conversación abierta, un mensaje dejado o POST /v1/chatbots/{id}/chats.
chat.message.createdMensaje nuevo en una conversación de chatbot (visitante, bot o una persona del equipo; las notas internas no). Sale por cada mensaje.
chat.closedSe cierra una conversación de chatbot (inactividad, visitante, panel o API), con su resumen.
chat.handed_offUna conversación de chatbot pasa a la cola de personas (lo pide el visitante, lo propone el bot o POST /v1/chats/{chat_id}/handoff).
chat.ratedEl visitante valora una conversación cerrada (1-5 y comentario opcional).
chat.qa_scoredLa evaluación con IA puntúa una conversación de chatbot que atendió una persona.

Existe además ping, que solo se emite con el botón Probar del panel. No se puede suscribir y no aparece en el catálogo.

Aviso de PII: transcript.ready incluye text, turns y qa — es decir, el contenido literal de la conversación. Lo mismo chat.message.created (el texto de cada mensaje), chat.closed (el resumen), chat.rated (el comentario del visitante) y chat.qa_scored (resumen e incidencias de la atención). chat.started, chat.closed y chat.handed_off llevan además visitor.email y visitor.name tal como los dio el visitante. Trata ese endpoint con el mismo cuidado que la grabación: TLS, control de acceso y una retención que puedas justificar.

2.bis Campos de data, evento por evento

Todo lo de aquí sale de crates/vs-core/src/webhooks.rs y de los puntos de emisión. Un campo puede llegar null si el dato no existe para esa llamada (una saliente no tiene queue_id, una que nadie contestó no tiene answered_at). Trata la ausencia y el null igual.

call.started

CampoTipoNotas
call_idstringULID del CDR. Es la clave con la que casar los demás eventos de la misma llamada.
directionstringinbound / outbound.
fromstringNúmero del origen, tal como llegó por SIP.
from_namestring | nullNombre a mostrar del origen, si el operador lo mandó.
tostringNúmero de destino.
to_namestring | nullNombre a mostrar del destino.
didstring | nullEl DDI con el que nos presentamos (callerID usado).
started_atRFC 3339Inicio de la llamada.
answered_atRFC 3339Momento de la contestación. En este evento nunca es null: es lo que lo dispara.
queue_idstring | nullCola por la que entró, si entró por una.

call.ended

Todos los de call.started (con answered_at ya nullable) más:

CampoTipoNotas
statusstringEstado final del CDR.
ended_atRFC 3339Fin de la llamada.
duration_msnúmero | nullDuración total en milisegundos.
hangup_reasonstring | nullMotivo del corte.
hangup_bystring | nullQuién colgó.
amd_resultstring | nullResultado del detector de contestador, si se usó.
has_recordingbooleanoSi hay grabación asociada. No incluye la URL: hay que pedirla por el panel.

call.transferred

CampoTipoNotas
call_idstringCDR de la llamada transferida.
target_typestringTipo de destino (extensión, cola, número…).
targetstringEl destino concreto.

transcript.ready

CampoTipoNotas
call_idstringCDR.
directionstringinbound / outbound.
from, tostringOrigen y destino.
ended_atRFC 3339 | nullFin de la llamada.
duration_msnúmero | nullDuración.
confidencenúmero | nullConfianza media del reconocimiento (0..1).
textstring | nullTranscripción literal completa.
turnsarray | nullTurnos con hablante y tiempos de lo hablado con el cliente. Si durante la llamada hubo una consulta entre dos empleados, sus turnos no van aquí: un webhook es un empuje automático a una URL de fuera, y esa conversación es interna. Se piden por la API (transcript_turns_consulta, con recordings:read), que es una lectura deliberada. [audit consulta-acotada]
qaobjeto | nullEvaluación de calidad, si el plan la incluye.

⚠️ Los cuatro últimos campos son datos personales. Es el único evento que transporta el contenido de la conversación. Ver el aviso de PII de la sección 2.

Los estados de transcription_status son pending · processing · success · failed · skipped (y NULL si la organización no transcribe). No hay done: esta tabla lo dijo durante meses y el filtro del barrido se escribió contra ese valor, así que el evento no se emitió nunca. [audit transcript-ready-done]

appointment.booked

El mismo type sale de tres sitios: la reserva del agente por voz, el alta del panel de Agenda y POST /v1/appointments. Significan lo mismo —una cita nueva—, pero el data no trae los mismos campos, así que la tabla dice cuál llega en cada caso.

CampoTipoQuién lo mandaNotas
call_idstringSolo por vozLlamada desde la que se reservó. Desde el panel y desde la API la clave no viene: no llega a null, no está. Un data.call_id === null no dispara — comprueba la ausencia.
providerstringLos tresnative, calcom o google. El panel y la API solo emiten con agenda nativa; con Cal.com o Google la cita vive en el sistema del cliente y es ese sistema el que avisa. Por voz sale el proveedor que tenga configurado la org.
event_idstringLos tresId de la cita en el proveedor. Es el que sirve para cancelarla o cruzarla, y el que casa con appointment.cancelled.
sourcestringPanel y APImanual = alta del panel; voice = todo lo demás. La API pública también dice voice, porque reserva por el mismo camino que el agente: este campo NO distingue tu integración del teléfono. Para eso está call_id.
startRFC 3339Los tresInicio de la cita.
endRFC 3339Panel y APIFin de la cita, sin buffer. Por voz no viene.
resource_idstringPanel y APIRecurso reservado (mesa, sala, sillón). Por voz no viene: si pintas la mesa asignada, esas citas —la mayoría— llegan sin ella.
party_sizenúmeroLos tresPersonas.
namestringLos tresNombre del cliente. Por voz, Cliente si no llegó a decirlo.
phonestring | nullLos tresTeléfono del cliente.
emailstring | nullLos tresCorreo, si lo dio.
notestring | nullLos tresTexto libre de la reserva.

Los tres últimos llegan null por voz cuando no hay dato, y como cadena vacía desde el panel y la API. Un if (data.email) cubre los dos; un === null, no.

appointment.cancelled

Solo agenda nativa, y sale de los tres sitios donde se puede anular: el agente por voz, el panel de Agenda y POST /v1/appointments/{id}/cancel. Con Cal.com o Google la baja la da su sistema, no el nuestro.

CampoTipoQuién lo mandaNotas
call_idstringSolo por vozLlamada en la que se anuló. Desde el panel y desde la API la clave no viene, igual que en booked.
providerstringLos tresSiempre native.
event_idstringLos tresLa cita anulada. Es el mismo id que llegó en booked: casa la baja por aquí.
startRFC 3339Los tresHora a la que era la cita.
resource_idstringLos tresRecurso que queda libre.
namestringLos tresNombre del cliente.
phonestringLos tresTeléfono del cliente.

No lleva end, ni source, ni party_size, ni email, ni note: lo que no venga aquí, sácalo del booked que ya recibiste con ese event_id.

callback.created

CampoTipoNotas
callback_idstringId de la devolución de llamada.
call_idstring | nullLlamada que la originó.
chat_idstring | nullConversación de chatbot que la pidió (schedule_callback, ya confirmada por el visitante).
phonestringNúmero al que hay que devolver la llamada.
queue_idstring | nullCola de origen.
list_idstring | nullLista/campaña a la que pertenece.
kindstringoverflow (desbordamiento de cola), reminder (recordatorio del agente de voz) o chatbot (la pidió un visitante en el chat de la web).
notestring | nullMotivo, si lo dio.
scheduled_atRFC 3339 | nullCuándo toca llamar. null = en cuanto se pueda.

campaign.finished

CampoTipoNotas
campaign_idstringId de la campaña.
namestringNombre.
callbacks_totalnúmeroTotal de llamadas de la campaña.
callbacks_donenúmeroCompletadas.
callbacks_failednúmeroFallidas.

campaign.contact_completed

Sale cuando termina el flujo de una llamada de campaña con agente IA que se contestó. Si no contestan, no sale (eso lo cuentan los reintentos de la campaña y call.ended). En campañas con agentes humanos no sale: allí el flujo solo precede a la persona.

CampoTipoNotas
campaign_idstringId de la campaña.
callback_idstringLa marcación: la fila del contacto dentro de la campaña.
call_idstringULID del CDR de esta llamada. Es también la clave de idempotencia.
phonestringNúmero llamado, E.164.
attemptnúmeroIntento en el que se habló.
outcomestring | nullLo que dejó el flujo en vars.resultado, saneado a [a-z0-9_]. Las plantillas de campaña usan una lista cerrada (p. ej. confirmada, cambiar, cancelar, no_es_la_persona, no_llamar). null si el flujo no lo fijó (buzón, cuelgue a mitad…).
opted_outbooltrue si outcome es no_llamar: el número ya está en la lista de no llamar de la organización.
varsobjetoVariables marcadas GUARDAR de la llamada (y resultado), todas en texto. Nunca las secretas. Contiene datos personales.

qa.scored

CampoTipoNotas
call_idstringCDR evaluado.
qaobjetoPuntuación y desglose. Su forma la fija la plantilla de QA de la org, así que no asumas campos fijos.

chat.started

CampoTipoNotas
chat_idstringId de la conversación: el mismo que devuelven /v1/chats/{chat_id} y los eventos vs:chat:* del widget.
chatbot_idstringChatbot que la atiende.
sourcestringembed (widget) o api.
previous_chat_idstring|nullConversación cerrada de la que viene: la nueva arranca con su resumen como contexto.
visitor.idstringVisitante.
visitor.customer_idstring|nullId de TU cliente, solo si llegó firmado (identidad firmada o API). Lo que teclea el visitante no sale aquí.
visitor.emailstring|nullEmail conocido del visitante.
visitor.email_verifiedbooltrue si el email llegó firmado.
visitor.namestring|nullNombre, si lo dio.
started_atRFC 3339

chat.message.created

CampoTipoNotas
chat_idstring
chatbot_idstring
message.idstringIdempotente: un reenvío tras un corte trae el mismo id. Úsalo para deduplicar.
message.seqintOrden de llegada dentro de la conversación y cursor de GET /v1/chats/{chat_id}/messages?after=.
message.chat_idstringLa conversación del mensaje (igual que chat_id).
message.rolestringvisitor, bot o agent (una persona del equipo). Las notas internas no se emiten.
message.bodystringTexto plano. Contenido literal de la conversación.
message.authorstring|nullNombre de la persona del equipo en los mensajes agent (el que vio el visitante).
message.confirmobjeto|nullEn mensajes del bot que proponen una acción: {id, summary, expires_at}. Se confirma con POST /v1/chats/{chat_id}/tool-proposals/{id}.
message.call_offerobjeto|nullEn mensajes del bot que ofrecen una llamada web: {reason}, la frase con la que el bot lo justifica (texto para enseñar, no un código). Es lo que hace que el widget pinte «Llamar ahora»; tu app puede enseñar su propio botón. El bot nunca llama por su cuenta.
message.atRFC 3339

Los rastros de la llamada web (role: "event", kind webcall_started / webcall_ended) no se emiten por este evento: se leen por GET /v1/chats/{chat_id} (ver docs/API.md, chatbots).

Todos los chat.* se emiten solo si algún endpoint de la organización está suscrito: el emisor lo comprueba antes con una caché de un minuto por org y evento, así que un endpoint nuevo tarda hasta 60 s en empezar a recibir cualquiera de ellos (no solo chat.message.created).

chat.closed

CampoTipoNotas
chat_idstring
chatbot_idstring
reasonstringidle (inactividad), visitor (pidió conversación nueva), agent (desde el panel) o api.
summarystring|nullResumen de dos o tres frases generado al cerrar. null si no hubo mensajes del visitante o el modelo falló.
resolvedbool|nullSi el modelo que resume juzga que la conversación quedó resuelta sin necesitar a nadie más. null si no hubo resumen o el modelo no se pronunció. Es lo que cuenta el panel de Resultados.
message_countintMensajes de la conversación (aproximado: se actualiza cada minuto).
closed_atRFC 3339
closed_bystring|nullUsuario que la cerró desde la bandeja o el panel; null si la cerró el sistema, el visitante o la API.
handled_by_user_idstring|nullLa persona que la atendió; null si solo la atendió el bot.
category_idstring|nullCategoría elegida al cerrar (las mismas de las llamadas). Si nadie la eligió, la propone el modelo al resumir, solo entre las categorías que existen.
visitorobjetoMismos campos que en chat.started.

Se emite una sola vez por cierre aunque dos caminos cierren a la vez: el cierre es condicional en BD y solo el que lo consigue avisa. La clave de idempotencia es el chat más el instante del cierre, así que una conversación que se reabre y se vuelve a cerrar emite otra vez (con el resumen final). Llega unos segundos DESPUÉS del cierre, porque espera al resumen.

chat.handed_off

CampoTipoNotas
chat_idstring
chatbot_idstring
reasonstringvisitor (botón del widget), el motivo que dio el bot en sus palabras, o el reason de la API (api si no mandaste ninguno).
handed_off_atRFC 3339
visitorobjetoMismos campos que en chat.started.

Solo sale cuando la conversación entra de verdad en la cola (hay alguien asignado y conectado, y está dentro del horario). Si no hay nadie, la conversación pasa a «deja tu mensaje» y no se emite. La clave de idempotencia es el chat más el instante del paso: una conversación que se reabre y vuelve a pedir persona emite otra vez.

chat.rated

CampoTipoNotas
chat_idstring
chatbot_idstring
ratingintDe 1 a 5.
commentstring|nullLo que escribió el visitante (hasta 1.000 caracteres). Texto literal.
handled_by_user_idstring|nullLa persona que atendió la conversación; null si solo la atendió el bot.

Una conversación se valora una vez: la segunda valoración se rechaza y no emite nada.

chat.qa_scored

CampoTipoNotas
chat_idstring
chatbot_idstring
handled_by_user_idstringLa persona evaluada.
qa.scoreint0-100.
qa.summarystringUna o dos frases. Puede citar la conversación.
qa.answersarrayUna entrada {q, a} por criterio o pregunta de la rúbrica.
qa.flagsarrayIncidencias citables; vacío si no hay.
qa.confidencenumber0-1. Baja con conversaciones triviales o transcripción recortada.

Estos campos son fijos: los produce siempre el evaluador de conversaciones de chatbot (en qa.scored de llamadas, en cambio, la forma depende de la plantilla de la org). Solo sale para chatbots con la evaluación activada, en planes con pasar a persona y evaluación de calidad, y para conversaciones con al menos un mensaje de una persona del equipo. Llega unos segundos después de chat.closed; si la evaluación falla se reintenta (hasta tres intentos en total, contando el primero) y no se emite nada mientras tanto. Sin saldo o con el tope diario del chatbot alcanzado se aplaza, y a los 7 días del cierre se abandona.


3. Cabeceras

CabeceraValor
Content-Typeapplication/json
X-Alpire-EventEl type del evento
X-Alpire-DeliveryULID de esta entrega. Estable entre reintentos → úsalo como clave de idempotencia.
X-Alpire-TimestampUnix en segundos, el que va firmado
X-Alpire-Signaturesha256=<hex>. Justo después de rotar el secreto, dos separadas por coma (§4).

No enviamos cabecera con el número de reintento. Para saber si es un reintento, mira si ya procesaste ese X-Alpire-Delivery.

4. Verificar la firma

Estilo Stripe: se firma "<timestamp>.<body-crudo>" con HMAC-SHA256 y el secreto del endpoint, en hexadecimal.

firma = hex( HMAC_SHA256( secreto, timestamp + "." + body ) )

El secreto se muestra una sola vez al crear el webhook. Si lo pierdes, rótalo desde el panel (botón «Rotar secreto» al editar el webhook): no hace falta borrarlo y recrearlo.

Cuatro reglas que no puedes saltarte:

  1. Firma sobre el body crudo, antes de parsear el JSON. Si serializas de nuevo, cambian espacios y orden y la firma no cuadra.
  2. Compara en tiempo constante (timingSafeEqual / hmac.compare_digest), no con ==.
  3. Rechaza la entrega si falta X-Alpire-Timestamp o X-Alpire-Signature, y después rechaza timestamps viejos (5 minutos es razonable) o aceptas replays. El orden importa: en JavaScript Number(undefined) es NaN y Math.abs(NaN) > 300 es false, así que un control de antigüedad sin comprobar antes la cabecera deja pasar una petición sin ella; en Python int("") lanza y tu servidor contesta 500.
  4. La cabecera puede traer varias firmas separadas por coma, y la entrega es buena si cualquiera cuadra con tu secreto.

Rotar el secreto sin cortes

Al rotar el secreto en el panel, durante 24 horas cada entrega lleva dos firmas: primero la del secreto nuevo y después la del anterior (sha256=<nueva>,sha256=<anterior>). Así puedes cambiar el secreto en tu automatización cuando te venga bien: mientras tengas el viejo, cuadra la segunda; en cuanto pongas el nuevo, cuadra la primera. Pasadas las 24 horas solo va la del nuevo. El panel enseña hasta cuándo dura la firma doble.

Si rotas porque el secreto se ha filtrado, cambia el tuyo cuanto antes: la firma doble no le da nada a quien tenga el viejo (con él ya podía firmar lo que quisiera), pero tu endpoint lo sigue aceptando hasta que lo cambies.

Node.js / Express

const crypto = require('crypto');

// La entrega es buena si CUALQUIERA de las firmas de la cabecera cuadra con tu secreto.
function firmaValida(secreto, ts, cuerpo, cabecera) {
  const esperada = Buffer.from('sha256=' + crypto
    .createHmac('sha256', secreto)
    .update(ts + '.' + cuerpo)
    .digest('hex'));
  return cabecera.split(',').some((f) => {
    const recibida = Buffer.from(f.trim());
    return recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada);
  });
}

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const ts   = req.get('X-Alpire-Timestamp');
  const sig  = req.get('X-Alpire-Signature');
  const body = req.body.toString('utf8');       // cuerpo CRUDO

  // Primero que existan: sin esto, Number(undefined) es NaN y el control de antigüedad
  // de la línea siguiente no rechaza nada.
  if (!ts || !sig || !/^\d+$/.test(ts)) return res.status(400).end();
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).end();
  if (!firmaValida(process.env.WEBHOOK_SECRET, ts, body, sig)) return res.status(401).end();

  const event = JSON.parse(body);
  // Idempotencia: si ya viste req.get('X-Alpire-Delivery'), responde 200 y no repitas.
  res.status(200).end();                        // responde YA, procesa en background
});

Python / Flask

import hmac, hashlib, time
from flask import request, abort

def firma_valida(secreto: bytes, ts: str, cuerpo: bytes, cabecera: str) -> bool:
    esperada = "sha256=" + hmac.new(secreto, ts.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
    # La entrega es buena si CUALQUIERA de las firmas cuadra (dos durante una rotación).
    return any(hmac.compare_digest(f.strip(), esperada) for f in cabecera.split(","))

@app.post("/webhook")
def webhook():
    ts   = request.headers.get("X-Alpire-Timestamp", "")
    sig  = request.headers.get("X-Alpire-Signature", "")
    body = request.get_data()                    # bytes crudos

    # Primero que existan y sean lo que dicen: int("") lanza y Flask contesta 500.
    if not ts.isdigit() or not sig:
        abort(400)
    if abs(time.time() - int(ts)) > 300:
        abort(400)
    if not firma_valida(SECRET.encode(), ts, body, sig):
        abort(401)

    return "", 200

5. Códigos de respuesta y reintentos

Lo que miramos es solo el código HTTP. El cuerpo de tu respuesta se ignora.

Tu respuestaQué hacemos
2xx (cualquiera)Entrega marcada como delivered. Fin.
Cualquier otro código (3xx, 4xx, 5xx)Reintento hasta agotar 8 intentos.
Error de red / TLS / timeoutReintento igual. Timeout de la petición: 10 s.
URL rechazada por el filtro anti-SSRFfailed inmediato, sin reintentos.
No se pudo resolver el nombre del endpointReintento igual (suele ser pasajero).
Endpoint borrado mientras esperabafailed inmediato, y su contenido se borra.

Tienes 10 segundos para responder. Responde 2xx en cuanto valides la firma y haz el trabajo en segundo plano; si procesas en línea y tardas, te reintentaremos una entrega que en realidad sí procesaste.

Backoff entre intentos (segundos), según intentos ya realizados:

Intentos hechosEspera hasta el siguiente
160 s
25 min
330 min
42 h
56 h
6 y siguientes24 h

Tras 8 intentos (unos 2,4 días) la entrega pasa a failed (dead-letter): aparece en la lista de Entregas del webhook con su último error, en Ajustes → Depuración como webhook_failed, y avisamos por correo a los administradores de la organización la primera vez que pasa. No volvemos a avisar de ese webhook hasta que entregue bien otra vez: con el endpoint caído fallan todas, y cien correos iguales no ayudan a nadie.

Reenviar. Una entrega failed de los últimos 7 días se puede reenviar desde «Entregas» (con el webhook activo): vuelve a la cola con los 8 intentos desde cero y el mismo X-Alpire-Delivery, que tu endpoint no llegó a procesar.

No hay orden garantizado

Las entregas salen en paralelo —varias a la vez, repartidas entre organizaciones y, con varios nodos, desde más de uno— y un reintento sale detrás de lo que se generó después: un call.started que falla a la primera puede llegarte después de su call.ended. Si tu integración necesita orden:

  1. Agrupa por el recurso: call_id, chat_id, el event_id de una cita, callback_id…
  2. Ordena por las marcas de tiempo de data (answered_at, ended_at, closed_at, handed_off_at…), no por el orden de llegada. El created_at del envelope sirve de desempate, pero es cuándo se generó el evento: en los tres eventos de barrido (call.*, transcript.ready) es el momento del barrido, no el de la llamada.
  3. Al actualizar tu copia, descarta un evento más viejo que el último que ya aplicaste para ese recurso.

⚠️ Un 4xx no detiene los reintentos. Hoy no distinguimos "no puedo procesarlo ahora" de "esto nunca va a funcionar": un 400 o un 410 Gone permanente se reintenta las 8 veces igual, repartidas a lo largo de más de un día. Si tu endpoint desaparece, borra o pausa el webhook en el panel; no basta con devolver un error.

6. Estado en el panel

En la lista de webhooks verás el resultado del último intento:

ValorSignificado
ok:200Último intento correcto, con su código.
err:500 (o cualquier otro código)Último intento devolvió ese código HTTP.
err:redNo se pudo conectar: TLS, timeout, conexión rechazada.
err:dnsNo pudimos resolver el nombre del endpoint en ese intento. Se reintenta con normalidad; si se repite, revisa el DNS de tu dominio.
err:URL rechazada: …La URL no es http/https o resuelve a una dirección no permitida (§7). Esas entregas pasan a failed sin reintentos: cambia la URL.
err:secretoNo pudimos descifrar el secreto del endpoint para firmar. Es un fallo nuestro y las entregas acaban en failed: rota el secreto y avísanos.

El botón Entregas enseña las últimas 100 de los últimos 7 días: cuándo, evento, estado, intentos, próximo intento y el último error de cada una, con «Reenviar» en las fallidas.

Pausar un webhook congela sus entregas pendientes (no gastan intentos mientras tanto) y se conservan 7 días: si no lo reactivas antes, se descartan. El panel te dice cuántas quedan al pausar.

Las entregas se guardan 7 días (retención corta a propósito: transcript.ready lleva PII) y luego se purgan.

7. Restricciones de la URL

Solo http:// y https://. Se resuelve el DNS antes de enviar y se rechaza si alguna IP resultante es privada o especial:

IPv4Qué es
0.0.0.0/8«Esta red» / sin especificar
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16Privadas (RFC 1918)
100.64.0.0/10CGNAT
127.0.0.0/8Loopback
169.254.0.0/16Link-local (metadata de nube)
192.0.0.0/24Asignaciones IETF (incluye NAT64)
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24Documentación
198.18.0.0/15Pruebas de rendimiento
224.0.0.0/4Multicast
240.0.0.0/4Reservadas (incluye 255.255.255.255)
IPv6Qué es
::1, ::Loopback y sin especificar
fc00::/7Locales únicas (ULA)
fe80::/10Link-local
ff00::/8Multicast
::ffff:0:0/96, ::/96, 64:ff9b::/96, 2002::/16IPv4 embebida (mapeada, compatible, NAT64 y 6to4): se valida la IPv4 de dentro con la tabla de arriba

La IP resuelta queda fijada para esa petición, así que un DNS rebinding tampoco entra.

Se valida al crear el webhook y en cada entrega. En la práctica: tu endpoint tiene que estar en Internet público. Para desarrollo, usa un túnel (ngrok, Cloudflare Tunnel).


Limitaciones conocidas

  • No hay orden garantizado entre entregas, ni siquiera del mismo recurso. Cómo ordenar, en §5.
  • Un 4xx no detiene los reintentos (§5): si tu endpoint desaparece, borra o pausa el webhook en el panel.
  • El data de appointment.booked no es el mismo según quién reserve: por voz no van end, source ni resource_id; desde el panel y desde la API no va call_id (y su clave se omite, no llega a null). La tabla de la sección 2.bis lo detalla campo a campo.
  • Los eventos call.started / call.ended / transcript.ready salen de un barrido cada 15 s, así que tienen esa latencia; los demás se emiten en el momento.