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": { }
}
| Campo | Descripción |
|---|---|
id | Id del evento, prefijo evt_ + ULID. Estable entre reintentos. |
type | Tipo de evento (tabla de abajo). |
created_at | RFC 3339 UTC, momento en que se generó el evento. |
org_id | Tu organización. |
data | Cuerpo 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
type | Cuándo |
|---|---|
call.started | La llamada es atendida (answered_at). Detectado por barrido cada 15 s. |
call.ended | La llamada termina (ended_at). Barrido cada 15 s. |
call.transferred | Transferencia efectiva entre patas. |
transcript.ready | La transcripción post-llamada pasa a success. Barrido cada 15 s. |
appointment.booked | Cita creada: por voz, desde el panel de Agenda o por POST /v1/appointments. |
appointment.cancelled | Cita anulada: por voz, desde el panel o por POST /v1/appointments/{id}/cancel. Solo agenda nativa. |
callback.created | Se programa una devolución de llamada. |
campaign.finished | Una campaña de marcación termina. |
campaign.contact_completed | Termina la conversación de una campaña con agente IA con un contacto, con su resultado y los datos recogidos. |
qa.scored | El análisis de calidad puntúa una llamada. |
chat.started | Empieza 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.created | Mensaje nuevo en una conversación de chatbot (visitante, bot o una persona del equipo; las notas internas no). Sale por cada mensaje. |
chat.closed | Se cierra una conversación de chatbot (inactividad, visitante, panel o API), con su resumen. |
chat.handed_off | Una 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.rated | El visitante valora una conversación cerrada (1-5 y comentario opcional). |
chat.qa_scored | La 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.readyincluyetext,turnsyqa— es decir, el contenido literal de la conversación. Lo mismochat.message.created(el texto de cada mensaje),chat.closed(el resumen),chat.rated(el comentario del visitante) ychat.qa_scored(resumen e incidencias de la atención).chat.started,chat.closedychat.handed_offllevan ademásvisitor.emailyvisitor.nametal 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
| Campo | Tipo | Notas |
|---|---|---|
call_id | string | ULID del CDR. Es la clave con la que casar los demás eventos de la misma llamada. |
direction | string | inbound / outbound. |
from | string | Número del origen, tal como llegó por SIP. |
from_name | string | null | Nombre a mostrar del origen, si el operador lo mandó. |
to | string | Número de destino. |
to_name | string | null | Nombre a mostrar del destino. |
did | string | null | El DDI con el que nos presentamos (callerID usado). |
started_at | RFC 3339 | Inicio de la llamada. |
answered_at | RFC 3339 | Momento de la contestación. En este evento nunca es null: es lo que lo dispara. |
queue_id | string | null | Cola por la que entró, si entró por una. |
call.ended
Todos los de call.started (con answered_at ya nullable) más:
| Campo | Tipo | Notas |
|---|---|---|
status | string | Estado final del CDR. |
ended_at | RFC 3339 | Fin de la llamada. |
duration_ms | número | null | Duración total en milisegundos. |
hangup_reason | string | null | Motivo del corte. |
hangup_by | string | null | Quién colgó. |
amd_result | string | null | Resultado del detector de contestador, si se usó. |
has_recording | booleano | Si hay grabación asociada. No incluye la URL: hay que pedirla por el panel. |
call.transferred
| Campo | Tipo | Notas |
|---|---|---|
call_id | string | CDR de la llamada transferida. |
target_type | string | Tipo de destino (extensión, cola, número…). |
target | string | El destino concreto. |
transcript.ready
| Campo | Tipo | Notas |
|---|---|---|
call_id | string | CDR. |
direction | string | inbound / outbound. |
from, to | string | Origen y destino. |
ended_at | RFC 3339 | null | Fin de la llamada. |
duration_ms | número | null | Duración. |
confidence | número | null | Confianza media del reconocimiento (0..1). |
text | string | null | Transcripción literal completa. |
turns | array | null | Turnos 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] |
qa | objeto | null | Evaluació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_statussonpending·processing·success·failed·skipped(y NULL si la organización no transcribe). No haydone: 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.
| Campo | Tipo | Quién lo manda | Notas |
|---|---|---|---|
call_id | string | Solo por voz | Llamada 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. |
provider | string | Los tres | native, 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_id | string | Los tres | Id de la cita en el proveedor. Es el que sirve para cancelarla o cruzarla, y el que casa con appointment.cancelled. |
source | string | Panel y API | manual = 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. |
start | RFC 3339 | Los tres | Inicio de la cita. |
end | RFC 3339 | Panel y API | Fin de la cita, sin buffer. Por voz no viene. |
resource_id | string | Panel y API | Recurso reservado (mesa, sala, sillón). Por voz no viene: si pintas la mesa asignada, esas citas —la mayoría— llegan sin ella. |
party_size | número | Los tres | Personas. |
name | string | Los tres | Nombre del cliente. Por voz, Cliente si no llegó a decirlo. |
phone | string | null | Los tres | Teléfono del cliente. |
email | string | null | Los tres | Correo, si lo dio. |
note | string | null | Los tres | Texto 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.
| Campo | Tipo | Quién lo manda | Notas |
|---|---|---|---|
call_id | string | Solo por voz | Llamada en la que se anuló. Desde el panel y desde la API la clave no viene, igual que en booked. |
provider | string | Los tres | Siempre native. |
event_id | string | Los tres | La cita anulada. Es el mismo id que llegó en booked: casa la baja por aquí. |
start | RFC 3339 | Los tres | Hora a la que era la cita. |
resource_id | string | Los tres | Recurso que queda libre. |
name | string | Los tres | Nombre del cliente. |
phone | string | Los tres | Telé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
| Campo | Tipo | Notas |
|---|---|---|
callback_id | string | Id de la devolución de llamada. |
call_id | string | null | Llamada que la originó. |
chat_id | string | null | Conversación de chatbot que la pidió (schedule_callback, ya confirmada por el visitante). |
phone | string | Número al que hay que devolver la llamada. |
queue_id | string | null | Cola de origen. |
list_id | string | null | Lista/campaña a la que pertenece. |
kind | string | overflow (desbordamiento de cola), reminder (recordatorio del agente de voz) o chatbot (la pidió un visitante en el chat de la web). |
note | string | null | Motivo, si lo dio. |
scheduled_at | RFC 3339 | null | Cuándo toca llamar. null = en cuanto se pueda. |
campaign.finished
| Campo | Tipo | Notas |
|---|---|---|
campaign_id | string | Id de la campaña. |
name | string | Nombre. |
callbacks_total | número | Total de llamadas de la campaña. |
callbacks_done | número | Completadas. |
callbacks_failed | número | Fallidas. |
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.
| Campo | Tipo | Notas |
|---|---|---|
campaign_id | string | Id de la campaña. |
callback_id | string | La marcación: la fila del contacto dentro de la campaña. |
call_id | string | ULID del CDR de esta llamada. Es también la clave de idempotencia. |
phone | string | Número llamado, E.164. |
attempt | número | Intento en el que se habló. |
outcome | string | null | Lo 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_out | bool | true si outcome es no_llamar: el número ya está en la lista de no llamar de la organización. |
vars | objeto | Variables marcadas GUARDAR de la llamada (y resultado), todas en texto. Nunca las secretas. Contiene datos personales. |
qa.scored
| Campo | Tipo | Notas |
|---|---|---|
call_id | string | CDR evaluado. |
qa | objeto | Puntuación y desglose. Su forma la fija la plantilla de QA de la org, así que no asumas campos fijos. |
chat.started
| Campo | Tipo | Notas |
|---|---|---|
chat_id | string | Id de la conversación: el mismo que devuelven /v1/chats/{chat_id} y los eventos vs:chat:* del widget. |
chatbot_id | string | Chatbot que la atiende. |
source | string | embed (widget) o api. |
previous_chat_id | string|null | Conversación cerrada de la que viene: la nueva arranca con su resumen como contexto. |
visitor.id | string | Visitante. |
visitor.customer_id | string|null | Id de TU cliente, solo si llegó firmado (identidad firmada o API). Lo que teclea el visitante no sale aquí. |
visitor.email | string|null | Email conocido del visitante. |
visitor.email_verified | bool | true si el email llegó firmado. |
visitor.name | string|null | Nombre, si lo dio. |
started_at | RFC 3339 |
chat.message.created
| Campo | Tipo | Notas |
|---|---|---|
chat_id | string | |
chatbot_id | string | |
message.id | string | Idempotente: un reenvío tras un corte trae el mismo id. Úsalo para deduplicar. |
message.seq | int | Orden de llegada dentro de la conversación y cursor de GET /v1/chats/{chat_id}/messages?after=. |
message.chat_id | string | La conversación del mensaje (igual que chat_id). |
message.role | string | visitor, bot o agent (una persona del equipo). Las notas internas no se emiten. |
message.body | string | Texto plano. Contenido literal de la conversación. |
message.author | string|null | Nombre de la persona del equipo en los mensajes agent (el que vio el visitante). |
message.confirm | objeto|null | En 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_offer | objeto|null | En 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.at | RFC 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
| Campo | Tipo | Notas |
|---|---|---|
chat_id | string | |
chatbot_id | string | |
reason | string | idle (inactividad), visitor (pidió conversación nueva), agent (desde el panel) o api. |
summary | string|null | Resumen de dos o tres frases generado al cerrar. null si no hubo mensajes del visitante o el modelo falló. |
resolved | bool|null | Si 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_count | int | Mensajes de la conversación (aproximado: se actualiza cada minuto). |
closed_at | RFC 3339 | |
closed_by | string|null | Usuario que la cerró desde la bandeja o el panel; null si la cerró el sistema, el visitante o la API. |
handled_by_user_id | string|null | La persona que la atendió; null si solo la atendió el bot. |
category_id | string|null | Categorí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. |
visitor | objeto | Mismos 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
| Campo | Tipo | Notas |
|---|---|---|
chat_id | string | |
chatbot_id | string | |
reason | string | visitor (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_at | RFC 3339 | |
visitor | objeto | Mismos 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
| Campo | Tipo | Notas |
|---|---|---|
chat_id | string | |
chatbot_id | string | |
rating | int | De 1 a 5. |
comment | string|null | Lo que escribió el visitante (hasta 1.000 caracteres). Texto literal. |
handled_by_user_id | string|null | La 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
| Campo | Tipo | Notas |
|---|---|---|
chat_id | string | |
chatbot_id | string | |
handled_by_user_id | string | La persona evaluada. |
qa.score | int | 0-100. |
qa.summary | string | Una o dos frases. Puede citar la conversación. |
qa.answers | array | Una entrada {q, a} por criterio o pregunta de la rúbrica. |
qa.flags | array | Incidencias citables; vacío si no hay. |
qa.confidence | number | 0-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
| Cabecera | Valor |
|---|---|
Content-Type | application/json |
X-Alpire-Event | El type del evento |
X-Alpire-Delivery | ULID de esta entrega. Estable entre reintentos → úsalo como clave de idempotencia. |
X-Alpire-Timestamp | Unix en segundos, el que va firmado |
X-Alpire-Signature | sha256=<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:
- Firma sobre el body crudo, antes de parsear el JSON. Si serializas de nuevo, cambian espacios y orden y la firma no cuadra.
- Compara en tiempo constante (
timingSafeEqual/hmac.compare_digest), no con==. - Rechaza la entrega si falta
X-Alpire-TimestampoX-Alpire-Signature, y después rechaza timestamps viejos (5 minutos es razonable) o aceptas replays. El orden importa: en JavaScriptNumber(undefined)esNaNyMath.abs(NaN) > 300esfalse, así que un control de antigüedad sin comprobar antes la cabecera deja pasar una petición sin ella; en Pythonint("")lanza y tu servidor contesta 500. - 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 respuesta | Qué hacemos |
|---|---|
| 2xx (cualquiera) | Entrega marcada como delivered. Fin. |
| Cualquier otro código (3xx, 4xx, 5xx) | Reintento hasta agotar 8 intentos. |
| Error de red / TLS / timeout | Reintento igual. Timeout de la petición: 10 s. |
| URL rechazada por el filtro anti-SSRF | failed inmediato, sin reintentos. |
| No se pudo resolver el nombre del endpoint | Reintento igual (suele ser pasajero). |
| Endpoint borrado mientras esperaba | failed 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 hechos | Espera hasta el siguiente |
|---|---|
| 1 | 60 s |
| 2 | 5 min |
| 3 | 30 min |
| 4 | 2 h |
| 5 | 6 h |
| 6 y siguientes | 24 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:
- Agrupa por el recurso:
call_id,chat_id, elevent_idde una cita,callback_id… - Ordena por las marcas de tiempo de
data(answered_at,ended_at,closed_at,handed_off_at…), no por el orden de llegada. Elcreated_atdel 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. - 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
400o un410 Gonepermanente 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:
| Valor | Significado |
|---|---|
ok:200 | Último intento correcto, con su código. |
err:500 (o cualquier otro código) | Último intento devolvió ese código HTTP. |
err:red | No se pudo conectar: TLS, timeout, conexión rechazada. |
err:dns | No 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:secreto | No 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:
| IPv4 | Qué es |
|---|---|
0.0.0.0/8 | «Esta red» / sin especificar |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | Privadas (RFC 1918) |
100.64.0.0/10 | CGNAT |
127.0.0.0/8 | Loopback |
169.254.0.0/16 | Link-local (metadata de nube) |
192.0.0.0/24 | Asignaciones IETF (incluye NAT64) |
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 | Documentación |
198.18.0.0/15 | Pruebas de rendimiento |
224.0.0.0/4 | Multicast |
240.0.0.0/4 | Reservadas (incluye 255.255.255.255) |
| IPv6 | Qué es |
|---|---|
::1, :: | Loopback y sin especificar |
fc00::/7 | Locales únicas (ULA) |
fe80::/10 | Link-local |
ff00::/8 | Multicast |
::ffff:0:0/96, ::/96, 64:ff9b::/96, 2002::/16 | IPv4 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
datadeappointment.bookedno es el mismo según quién reserve: por voz no vanend,sourceniresource_id; desde el panel y desde la API no vacall_id(y su clave se omite, no llega anull). La tabla de la sección 2.bis lo detalla campo a campo. - Los eventos
call.started/call.ended/transcript.readysalen de un barrido cada 15 s, así que tienen esa latencia; los demás se emiten en el momento.