Webhooks
Un run contra un portal del Estado puede tardar minutos. Sostener una petición HTTP ese
tiempo es frágil, así que POST /v1/runs responde 202 y el
resultado llega acá.
Registrar un endpoint
Con POST /v1/webhook_endpoints,
o pasando webhook_url al crear una sesión. La respuesta trae el
webhook_secret una sola vez: si no lo guardas en ese
momento, hay que rotarlo.
Eventos
| Evento | Cuándo |
|---|---|
run.completed | El run terminó bien. Trae los datos normalizados y los enlaces a sus documentos. |
run.failed | El run falló. Trae el code del catálogo de errores. |
session.completed | Todos los runs de una sesión del widget terminaron. |
session.failed | La sesión terminó mal — clave rechazada, expirada o sin runs que ejecutar. |
Verificar la firma
Cada entrega va firmada con HMAC-SHA256 sobre {timestamp}.{cuerpo}, usando
tu webhook_secret. Verifica antes de confiar en el cuerpo y
antes de parsearlo: si no, tu sistema procesa lo que cualquiera le mande.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function firmaValida(cuerpoCrudo, cabecera, secreto) {
// klevy-signature: t=1754700000,v1=9f86d081...
const partes = Object.fromEntries(
cabecera.split(',').map((p) => p.split('=')),
);
if (!partes.t || !partes.v1) return false;
// Una firma vieja es una firma robada: se rechaza pasados 5 minutos.
const edad = Math.abs(Date.now() / 1000 - Number(partes.t));
if (edad > 300) return false;
const esperada = createHmac('sha256', secreto)
.update(`${partes.t}.${cuerpoCrudo}`)
.digest('hex');
// Comparación en tiempo constante: un `===` filtra el secreto byte a byte.
const a = Buffer.from(esperada, 'hex');
const b = Buffer.from(partes.v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}import hmac, hashlib, time
def firma_valida(cuerpo_crudo: bytes, cabecera: str, secreto: str) -> bool:
partes = dict(p.split("=", 1) for p in cabecera.split(","))
if "t" not in partes or "v1" not in partes:
return False
# Una firma vieja es una firma robada.
if abs(time.time() - int(partes["t"])) > 300:
return False
esperada = hmac.new(
secreto.encode(),
f'{partes["t"]}.'.encode() + cuerpo_crudo,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(esperada, partes["v1"]) Reintentos
Se considera entregado cualquier 2xx. Cualquier otra cosa —o un timeout— se
reintenta con retroceso exponencial. Responde rápido y procesa después: si tu endpoint
tarda, la entrega se cuenta como fallida aunque la hayas recibido.
Una entrega puede repetirse. Trata el id del evento como clave de
idempotencia y descarta el que ya procesaste.
Ejemplo de entrega
POST /webhooks/klevy
klevy-signature: t=1754700000,v1=9f86d081884c7d65...
content-type: application/json
{
"id": "evt_7hq2n4kx0m1pz8v",
"type": "run.completed",
"created_at": "2026-08-09T12:34:07.000Z",
"livemode": true,
"data": {
"run_id": "run_3kf82ncq7p1xw0a",
"flow": "sii.rcv_resumen",
"status": "completed",
"session_id": "ses_ra1h4fzpatgae33c"
}
}