Webhooks de eventos
Recibe en tu sistema, firmado, lo que pasa en Calligence: cada llamada que sale, que no conecta, que termina y que se analiza; los mensajes de WhatsApp; los cambios de estado de tus leads (próximamente). Esta guía explica cómo recibirlos: cómo crear un webhook, cómo comprobar que cada evento es nuestro y qué hacemos cuando tu servidor no contesta. El formato exacto de cada tipo está en el catálogo de eventos.
Qué son
#Un webhook es una URL tuya a la que Calligence hace POST cada vez que ocurre algo a lo que te has suscrito. Cada petición lleva un evento, firmado con el secreto de ese webhook. Si tu servidor no contesta con un 2xx, lo reintentamos.
No es lo mismo que la conexión Webhook de Integraciones, que sustituye a un CRM en una campaña concreta (nos envías leads y te devolvemos el resultado). Los webhooks de eventos son notificaciones de toda tu organización: puedes tener los dos a la vez.
Crear un webhook
#En la consola, Configurar → Webhooks → Nuevo webhook. Lo pueden hacer los administradores de la organización; hay un máximo de 10 por organización.
- URL: https pública, sin usuario, puerto ni dirección IP. No puede apuntar a Calligence ni a una dirección privada.
- Eventos: los tipos que quieres recibir, agrupados en Llamadas, WhatsApp y Leads (próximamente). Ver el catálogo.
- Campañas: «Todas», o solo algunas. Ver los casos límite para lo que no pertenece a ninguna campaña.
- Incluir contenido de las conversaciones: si se envían transcripciones, mensajes de WhatsApp y el texto de notas de voz. Ver el contenido.
Al guardarlo se muestra el secreto de firma una sola vez (whsec_…). Guárdalo: no se puede volver a ver, solo rotar con Rotar secreto, que lo sustituye al momento. Con Enviar prueba recibes un evento webhook.test (su formato) para comprobar tu receptor sin esperar a una llamada.
En Más acciones están Editar, Desactivar / Activar y Eliminar. Desactivar cancela las entregas pendientes, y mientras está desactivado no le enviamos eventos nuevos: al activarlo de nuevo recibe solo los eventos nuevos.
La petición
#POST <tu URL> Content-Type: application/json User-Agent: Calligence-Webhooks/1 webhook-id: evt_273932f5d0f839406081315a1136aa4d webhook-timestamp: 1791192630 webhook-signature: v1,+QD9/Wq7BBK6X+HOHQHtrTmDxHaS7PXlQN3vKKHLfds=
webhook-id: el id del evento (igual queiden el cuerpo). Es el mismo en cada reintento y en cada webhook que reciba ese evento.webhook-timestamp: el momento de este intento, en segundos Unix. Cambia en cada reintento.webhook-signature: la firma de este intento. Ver la firma.
El cuerpo es un evento CloudEvents 1.0 en JSON. Todo va dentro del cuerpo, así que la firma lo cubre entero:
| Clave | Qué es |
|---|---|
specversion | Siempre 1.0. |
id | El id del evento, evt_…. Igual que webhook-id. |
source | urn:calligence:<entorno>:org:<id>. Ver entornos. |
type | El tipo de evento. Ver el catálogo. |
subject | De qué trata: calls/<id>, whatsapp_messages/<id> o leads/<id>. |
time | Cuándo ocurrió, ISO-8601 en UTC (Z). |
datacontenttype | Siempre application/json. |
dataschema | El esquema JSON de data para este tipo y su versión. Ver versiones. |
sequence | El orden del evento: time en microsegundos, con ceros a la izquierda (17 dígitos). Ver orden y duplicados. |
data | Los datos del evento, distintos para cada tipo. |
En la petición viaja compacto y en ASCII
Los ejemplos del catálogo están formateados para leerlos. El cuerpo real no lleva espacios ni saltos de línea y los caracteres no ASCII viajan escapados (María viaja como Mar\u00eda). Tu parser de JSON lo deshace solo; lo único que importa es verificar la firma sobre el cuerpo en crudo, antes de parsearlo.
Verificar la firma
#Seguimos Standard Webhooks, así que cualquier librería de Standard Webhooks verifica nuestras entregas. La firma es un HMAC-SHA256, en base64, de <webhook-id>.<webhook-timestamp>.<cuerpo en crudo>, con la clave que va detrás de whsec_ en tu secreto (decodificada de base64).
Verifica sobre el cuerpo EN CRUDO
Si tu framework te da el cuerpo ya convertido en un objeto y lo vuelves a serializar, la firma no cuadrará: cambia un espacio o el escapado y el HMAC es otro. Pide el cuerpo sin procesar, verifica, y parsea después. Rechaza también una marca de tiempo a más de 5 minutos de tu reloj: las librerías ya lo hacen.
Python
#Con el paquete standardwebhooks (pip install standardwebhooks), en un endpoint de FastAPI:
from fastapi import FastAPI, HTTPException, Request, Response
from standardwebhooks import Webhook, WebhookVerificationError
# El secreto de TU webhook (se muestra una vez, al crearlo). Este es el de prueba de esta página.
SECRETO = "whsec_Y2FsbGlnZW5jZS1kb2NzLXRlc3Qtc2VjcmV0LTAwMDE="
RUTA = "/webhooks/calligence"
webhook = Webhook(SECRETO)
app = FastAPI()
def verificar(cuerpo: bytes, cabeceras) -> dict:
"""El evento, si la firma es nuestra. Si no, lanza WebhookVerificationError."""
return webhook.verify(cuerpo, dict(cabeceras))
@app.post(RUTA)
async def recibir(request: Request) -> Response:
cuerpo = await request.body() # EN CRUDO: nunca lo vuelvas a serializar antes de verificar
try:
evento = verificar(cuerpo, request.headers)
except WebhookVerificationError:
raise HTTPException(status_code=400)
# evento["id"] es la cabecera webhook-id: si ya lo procesaste, ignóralo.
# Responde rápido y procesa después (por ejemplo, en una cola).
return Response(status_code=204)
Node.js
#Con el paquete standardwebhooks (npm install standardwebhooks), en Express:
import express from "express";
import { Webhook } from "standardwebhooks";
// El secreto de TU webhook ("whsec_…"), por ejemplo desde una variable de entorno.
const webhook = new Webhook(process.env.CALLIGENCE_WEBHOOK_SECRET);
const app = express();
// express.raw: el cuerpo EN CRUDO, tal y como llegó. La firma va sobre esos bytes.
app.post("/webhooks/calligence", express.raw({ type: "application/json" }), (req, res) => {
let evento;
try {
evento = webhook.verify(req.body, req.headers);
} catch (err) {
return res.status(400).end();
}
// evento.id es la cabecera webhook-id: si ya lo procesaste, ignóralo.
res.status(204).end();
});
app.listen(3000);
A mano, en cualquier lenguaje
#Sin librería, son cuatro pasos. En Python, solo con la librería estándar:
import base64
import hashlib
import hmac
import time
def verificar_a_mano(secreto: str, cabeceras: dict, cuerpo: bytes, tolerancia: int = 300) -> bool:
cab = {k.lower(): v for k, v in cabeceras.items()}
msg_id, marca, firmas = cab["webhook-id"], cab["webhook-timestamp"], cab["webhook-signature"]
# 1. La marca de tiempo, a menos de 5 minutos de tu reloj (protege de reenvíos maliciosos).
if abs(time.time() - int(marca)) > tolerancia:
return False
# 2. La clave: lo que va detrás de "whsec_", decodificado de base64.
clave = base64.b64decode(secreto.removeprefix("whsec_"))
# 3. HMAC-SHA256 de "<webhook-id>.<webhook-timestamp>.<cuerpo en crudo>", en base64.
firmado = f"{msg_id}.{marca}.".encode() + cuerpo
esperada = base64.b64encode(hmac.new(clave, firmado, hashlib.sha256).digest()).decode()
# 4. La cabecera trae una o más firmas "v1,<base64>" separadas por espacios.
return any(
version == "v1" and hmac.compare_digest(firma, esperada)
for version, _, firma in (f.partition(",") for f in firmas.split(" "))
)
Secreto y vector de prueba
#Para probar tu verificador sin crear un webhook, usa este secreto de prueba: no es el de ningún webhook real y no sirve para nada más. whsec_Y2FsbGlnZW5jZS1kb2NzLXRlc3Qtc2VjcmV0LTAwMDE=
Esta es una entrega de webhook.test firmada con él. Tu verificador debe aceptarla si desactivas la comprobación de la marca de tiempo (es de una fecha fija) y rechazarla si cambias un solo byte del cuerpo:
webhook-id: evt_273932f5d0f839406081315a1136aa4d webhook-timestamp: 1791192630 webhook-signature: v1,+QD9/Wq7BBK6X+HOHQHtrTmDxHaS7PXlQN3vKKHLfds=
{"specversion":"1.0","id":"evt_273932f5d0f839406081315a1136aa4d","source":"urn:calligence:pro:org:42","type":"webhook.test","subject":"endpoints/3","time":"2026-10-05T09:30:30Z","datacontenttype":"application/json","dataschema":"https://calligence.ai/docs/webhooks/schemas/webhook.test/v1.json","sequence":"01791192630000000","data":{"endpoint_id":3,"note":"Evento de prueba de Calligence"}}
Respuestas, reintentos y desactivación
#Contesta con un 2xx en menos de 15 segundos y procesa el evento después. Cualquier otra cosa es un fallo y se reintenta: un 3xx (no seguimos redirecciones), un 4xx (también 429), un 5xx, un timeout o un error de red.
- El primer intento sale al momento. Si falla, hay hasta 7 reintentos (8 intentos en total), cada uno a este intervalo del anterior: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, con un ±10 % de margen, siempre dentro de una ventana de 30 h desde el evento. Si contestas con
Retry-After, esperamos al menos eso (hasta 10 h). - Agotados los intentos o la ventana, la entrega queda fallida.
- Un
410 Gonedesactiva el webhook al momento: es la forma de decirnos que esa URL ya no existe. - 10 fallos seguidos sin ninguna entrega correcta en 5 días también lo desactivan.
- En los dos casos avisamos por email a los administradores de tu organización. Mientras está desactivado no le enviamos eventos nuevos y sus entregas pendientes se cancelan.
- Cada entrega queda en Entregas, con sus intentos y el cuerpo exacto que se envió (Ver payload). Reenviar vuelve a enviar una entrega entregada o fallida con una ventana nueva de 30 h; el webhook tiene que estar activo. Guardamos los eventos 30 días.
Duplicados y orden
#Una entrega puede llegar más de una vez: si tu respuesta se pierde, reintentamos, y Reenviar repite el mismo evento. Usa webhook-id para deduplicar: guarda los que ya procesaste e ignora los repetidos. (Si reenvías a propósito para reprocesar, bórralo antes de tu registro.)
No garantizamos el orden de llegada. Para saber qué es más reciente, compara sequence entre eventos del mismo subject: de una misma llamada, call.dispatched < call.ended < call.analyzed. Entre subjects distintos no significa nada.
Cada evento se emite una sola vez aunque nuestro proveedor nos avise dos veces, pero cuántos hay de cada tipo para un mismo subject depende del tipo (lo indica cada uno en el catálogo):
- Los
call.*: como mucho uno de cada tipo por llamada. lead.status_changed: uno por cada cambio de estado; un mismo lead puede tener muchos. Ordénalos porsequence.whatsapp.message.status_updated: uno por mensaje y estado.- Los demás de WhatsApp: uno por mensaje (
whatsapp.message.media_processed, uno por adjunto).
El contenido de las conversaciones
#Los campos marcados contenido (transcripciones, texto de WhatsApp, texto de un adjunto) solo llevan valor si el webhook tiene Incluir contenido de las conversaciones. Si no, llegan a null: la clave está siempre, el valor no.
call.ended.transcript_availabledice si la llamada tiene transcripción aunque tu webhook no la reciba.- Si un cuerpo supera 256 KB, quitamos la transcripción (
transcript: null) y lo marcamos contranscript_truncated: true. - El interruptor solo controla las transcripciones y los textos. El resumen (
summary) y los datos extraídos (extracted) decall.analyzedse envían siempre, y salen de la conversación: pueden contener datos personales del cliente.
Entornos
#source dice de dónde viene el evento: urn:calligence:pro:org:<id> en producción y urn:calligence:pre:org:<id> en el entorno de pruebas. Cada entorno tiene sus propios webhooks y secretos.
Versiones y compatibilidad
#Cada tipo de evento lleva su versión en type y en dataschema: call.ended es la versión 1 de ese tipo, y una versión 2 se llamaría call.ended.v2. Una versión publicada solo crece, así que tu receptor debe ignorar lo que no conozca: campos, tipos y valores. Un cambio incompatible llega como tipo nuevo, enviado junto al anterior durante al menos 6 meses.
Las reglas completas están en Versiones y compatibilidad, y cada cambio, en Cambios.
Solución de problemas
#La firma nunca me cuadra
#Casi siempre es un cuerpo re-serializado: verifica sobre los bytes en crudo. Comprueba también que usas el secreto de este webhook (tras Rotar secreto el anterior deja de valer al momento) y que tu reloj está en hora. Prueba tu código con el vector de prueba.
Me llegan eventos repetidos
#Es esperable: deduplica por webhook-id. Comparar cuerpos no sirve: la firma y webhook-timestamp cambian en cada intento.
Mi webhook aparece desactivado
#Lo desactivó un 410 o una racha de fallos (ver reintentos). Mientras está desactivado no le enviamos eventos nuevos: lo que ocurre entonces no genera entregas, y las pendientes se cancelaron al desactivarlo. Arregla el receptor, pulsa Activar y usa Enviar prueba; desde ese momento recibe solo los eventos nuevos. Reenviar, en Entregas, repite las entregadas o fallidas de antes de la desactivación; las canceladas no se pueden reenviar.
¿Sigues atascado? Escríbenos y lo miramos contigo.