Webhooks
Un webhook entrega cada verificación terminada a su servidor, de modo que nunca tiene que sondear. Registra una vez un endpoint HTTPS y, desde entonces, cada verificación que llega a un estado terminal se envía a él como un POST firmado. El sondeo de GET /v1/checks/{check_id} sigue disponible como respaldo.
Qué se entrega
Hay un solo tipo de evento, check.completed. Se envía una vez por cada verificación que llega a un estado terminal, completed o failed, incluido cada elemento de un lote (una entrega por elemento, no por lote). Una verificación que sigue pending no envía nada.
Todos los endpoints activos del entorno reciben el evento, y cada endpoint recibe su propia entrega. Los endpoints de una clave sandbox reciben solo eventos sandbox y los de una clave live solo eventos live; el campo environment indica cuál.
El cuerpo es JSON:
| Campo | Descripción |
|---|---|
event_id | Id único del evento. El mismo id se envía de nuevo en cada reintento de la misma entrega: deduplique por él. |
event_type | check.completed. |
environment | live o sandbox. |
created_at | Cuándo se creó el evento, una marca de tiempo ISO 8601. Idéntica en cada reintento. |
check | La verificación completa, con los mismos campos que data en la respuesta de GET /v1/checks/{check_id}; vea Verificaciones de dirección. |
{
"event_id": "9d2f6c1a-3b7e-4f08-a5d4-1c8e0b7a6f23",
"event_type": "check.completed",
"environment": "live",
"created_at": "2026-10-27T09:14:04.931Z",
"check": {
"check_id": "5b0c3f64-1a52-4c8e-9f3e-2f6c1d7a9e10",
"status": "completed",
"band": "high",
"score": 74
}
}
El ejemplo reduce check a unos pocos campos; el objeto entregado es la verificación completa. Trate la entrega como el mismo registro que obtendría al leer la verificación: un veredicto no cambia una vez que es terminal.
Registre un endpoint
POST /v1/webhooks con el scope webhooks:manage.
| Campo | Obligatorio | Descripción |
|---|---|---|
url | sí | La dirección HTTPS que recibe las entregas, de hasta 2048 caracteres. Los campos desconocidos se rechazan. |
La URL debe usar https. Su host se resuelve al registrarla, y un host que resuelve a una dirección dentro de un rango privado o reservado se rechaza con 400. La misma comprobación se repite antes de cada entrega, porque el DNS puede redirigirse después del registro. Si el host no puede resolverse, la solicitud responde 503. Cada entorno puede tener hasta 20 endpoints activos; el 21.º se rechaza con 400.
- curl
- Node.js
- Python
curl -s -X POST https://api.leeri.ai/v1/webhooks \
-H "Authorization: Bearer $LEERI_API_KEY" \
-H "Idempotency-Key: register-orders-endpoint" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-service.example.com/webhooks/leeri"}'
const response = await fetch('https://api.leeri.ai/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LEERI_API_KEY}`,
'Idempotency-Key': 'register-orders-endpoint',
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: 'https://your-service.example.com/webhooks/leeri' }),
});
const { data } = await response.json();
// data.secret is shown only in this response.
console.log(data.webhook_id, data.secret);
import os
import requests
response = requests.post(
"https://api.leeri.ai/v1/webhooks",
headers={
"Authorization": f"Bearer {os.environ['LEERI_API_KEY']}",
"Idempotency-Key": "register-orders-endpoint",
},
json={"url": "https://your-service.example.com/webhooks/leeri"},
timeout=30,
)
data = response.json()["data"]
# data["secret"] is shown only in this response.
print(data["webhook_id"], data["secret"])
La respuesta 201 contiene el endpoint:
| Campo | Descripción |
|---|---|
webhook_id | Id del endpoint, que se usa para rotarlo o desactivarlo. |
url | La URL registrada. |
secret | El secreto de firma. Se muestra una sola vez, solo en esta respuesta. Guárdelo ahora en su gestor de secretos: no se almacena de forma legible y no se vuelve a devolver, ni siquiera cuando la misma solicitud se repite con el mismo Idempotency-Key. |
secret_last4 | Los últimos cuatro caracteres del secreto, para distinguir unos secretos de otros. |
status | active, o disabled tras desactivarlo. |
previous_secret_expires_at | null, o el final de la ventana de rotación; vea Rote el secreto. |
rotated_at, created_at, updated_at | Marcas de tiempo. |
Si perdió el secreto, rote el endpoint para obtener uno nuevo.
Listar, rotar y desactivar
Las tres operaciones requieren el scope webhooks:manage y aceptan un Idempotency-Key opcional cuando modifican algo (vea Idempotencia).
GET /v1/webhookslista los endpoints del entorno de la clave, activos y desactivados, del más antiguo al más reciente. El listado muestrasecret_last4y nunca el secreto.POST /v1/webhooks/{webhook_id}/rotateemite un secreto nuevo; vea más abajo.DELETE /v1/webhooks/{webhook_id}desactiva el endpoint. Desde entonces no recibe nada, incluidas las entregas que aún esperaban un reintento. Desactivar un endpoint ya desactivado no es un error, y un endpoint desactivado deja de contar para el límite de 20. Un id desconocido, o de otro entorno, responde404.
Rote el secreto
Una rotación sustituye el secreto sin interrupción. Durante 24 horas son válidos tanto el secreto nuevo como el anterior: cada entrega de esa ventana se firma con ambos, de modo que un receptor que ya cambió al secreto nuevo y otro que aún no lo hizo la verifican igualmente. previous_secret_expires_at indica cuándo termina la ventana. El secreto nuevo está en la respuesta 200, una sola vez.
Despliegue el secreto nuevo en su receptor dentro de la ventana y deje de usar el anterior. Su código de verificación no cambia: ya acepta un encabezado con varias firmas.
Verifique la firma
Cualquiera que conozca su URL puede enviarle una solicitud. Toda entrega auténtica lleva una firma en el encabezado x-aml-signature, y una solicitud sin una firma válida no debe considerarse fiable ni actuarse en consecuencia.
x-aml-signature: t=1793121567192,v1=4abc621d…d9d2
tes el momento en que se firmó la entrega, en milisegundos Unix.v1es un HMAC-SHA256 codificado en hexadecimal, de 64 caracteres. Hay unv1por cada secreto vigente, así que durante una rotación el encabezado lleva dos. Basta con que coincida cualquiera de ellos.- El mensaje firmado es la cadena
<t>.<cuerpo sin procesar>: el valor det, un punto y el cuerpo de la solicitud exactamente como llegó. La clave es el secreto de su endpoint.
Para verificar una entrega:
- Lea el cuerpo sin procesar antes de analizarlo. La firma cubre los bytes transmitidos, así que un cuerpo que se analizó y se volvió a serializar ya no coincide.
- Extraiga
ty los valoresv1del encabezado. Rechace la solicitud si faltato todos losv1, o están mal formados. - Recházela si
tdifiere de su reloj en más de 5 minutos, en cualquier sentido. Así se evita reproducir más tarde una entrega capturada. - Calcule el HMAC-SHA256 de
<t>.<cuerpo sin procesar>con su secreto y compárelo con cadav1mediante una comparación de tiempo constante.
Los ejemplos siguientes hacen exactamente esto y nunca lanzan una excepción ante un encabezado mal formado. Están probados con firmas producidas por el propio servicio de entrega: una firma correcta, un cuerpo modificado, una marca de tiempo caducada, dos firmas durante una rotación y encabezados mal formados.
- Node.js
- Python
import crypto from 'node:crypto';
// Pass the raw request body exactly as received, before any JSON parsing: the signature covers
// the transmitted bytes, and re-serialising the parsed object changes them.
export function verifyWebhook(rawBody, header, secret, nowMs = Date.now(), maxSkewMs = 300000) {
if (typeof header !== 'string') return false;
let t = null;
const signatures = [];
for (const part of header.split(',')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const key = part.slice(0, eq);
const value = part.slice(eq + 1);
if (key === 't' && /^\d+$/.test(value)) t = Number(value);
else if (key === 'v1' && /^[0-9a-fA-F]{64}$/.test(value)) signatures.push(Buffer.from(value, 'hex'));
}
if (t === null || signatures.length === 0) return false;
if (Math.abs(nowMs - t) > maxSkewMs) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
// During a secret rotation the header carries one v1 per valid secret; any match is enough.
return signatures.some((signature) => crypto.timingSafeEqual(signature, expected));
}
// Respond 2xx quickly and process asynchronously. Deliveries are at-least-once, so dedupe on
// the event_id in the payload before acting on it.
import hashlib
import hmac
import re
import time
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
# Pass the raw request body exactly as received, before any JSON parsing: the signature covers
# the transmitted bytes, and re-serialising the parsed object changes them.
def verify_webhook(raw_body: bytes, header: str, secret: str, now_ms=None, max_skew_ms=300000) -> bool:
if not isinstance(header, str):
return False
if now_ms is None:
now_ms = int(time.time() * 1000)
t = None
signatures = []
for part in header.split(","):
key, sep, value = part.partition("=")
if not sep:
continue
if key == "t" and value.isascii() and value.isdigit():
t = int(value)
elif key == "v1" and _HEX64.match(value):
signatures.append(bytes.fromhex(value))
if t is None or not signatures:
return False
if abs(now_ms - t) > max_skew_ms:
return False
expected = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + raw_body, hashlib.sha256).digest()
# During a secret rotation the header carries one v1 per valid secret; any match is enough.
return any(hmac.compare_digest(signature, expected) for signature in signatures)
# Respond 2xx quickly and process asynchronously. Deliveries are at-least-once, so dedupe on
# the event_id in the payload before acting on it.
Un receptor mínimo
Conecte la función a una ruta que conserve el cuerpo sin procesar, responda rápido y haga el trabajo real después.
- Node.js
- Python
import express from 'express';
import { verifyWebhook } from './verify-webhook.mjs';
const app = express();
// express.raw keeps the body as bytes; express.json would parse it first and lose them.
app.post('/webhooks/leeri', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body, req.get('x-aml-signature'), process.env.LEERI_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
res.sendStatus(200);
const event = JSON.parse(req.body.toString('utf8'));
queue.add(event); // process later, skipping event.event_id values already handled
});
import os
from flask import Flask, request
from verify_webhook import verify_webhook
app = Flask(__name__)
@app.post("/webhooks/leeri")
def leeri_webhook():
raw_body = request.get_data() # bytes, before any JSON parsing
if not verify_webhook(raw_body, request.headers.get("x-aml-signature", ""), os.environ["LEERI_WEBHOOK_SECRET"]):
return "", 400
enqueue(request.get_json()) # process later, skipping event_id values already handled
return "", 200
Ambos receptores suponen una cola propia (queue.add, enqueue). El secreto proviene de su entorno o de su gestor de secretos, nunca del código fuente.
Entrega y reintentos
- Confirme con cualquier
2xxen un plazo de 10 segundos. El cuerpo de la respuesta no se usa: un204vacío basta, y un cuerpo inusualmente grande cuenta como fallo. - Las redirecciones no se siguen. Una respuesta
3xxcuenta como entrega fallida, así que registre la URL final. - Fallos que se reintentan: ninguna respuesta o una respuesta más lenta que 10 segundos,
408,429y todo5xx. Un fallo temporal de DNS también se reintenta. - No se reintentan: cualquier otro
4xxy cualquier3xx, porque su servidor rechazó la entrega de forma explícita, y una URL que ahora resuelve a una dirección bloqueada. Esas entregas terminan como fallidas y no se vuelven a enviar. - Calendario de reintentos: el primer reintento es inmediato, después a los 10 segundos, 1 minuto y 5 minutos, luego cada 10 minutos mientras la entrega tenga menos de 24 horas y después cada hora. Una entrega que sigue fallando 48 horas después de crearse se abandona.
- Al menos una vez. Una entrega puede llegar más de una vez, por ejemplo cuando su servidor la procesó pero su
2xxse perdió. Conserve los valores deevent_idya tratados e ignore las repeticiones. - No se garantiza el orden. Las entregas de distintas verificaciones, y los reintentos, pueden llegar desordenados. Base su gestión en el
check_id, no en el orden de llegada.
Una entrega fallida nunca afecta a la verificación ni a su cobro. Si su endpoint estuvo caído más tiempo del que cubre el calendario anterior, lea las verificaciones que perdió con GET /v1/checks/{check_id}.
Pruébelo
Registre un endpoint con una clave sandbox, ejecute una verificación sandbox y confirme que su receptor verifica la firma y responde 2xx. Los endpoints sandbox reciben solo eventos sandbox, así que nada llega a su endpoint live; vea Sandbox y live.