Saltar al contenido principal

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:

CampoDescripción
event_idId único del evento. El mismo id se envía de nuevo en cada reintento de la misma entrega: deduplique por él.
event_typecheck.completed.
environmentlive o sandbox.
created_atCuándo se creó el evento, una marca de tiempo ISO 8601. Idéntica en cada reintento.
checkLa 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.

CampoObligatorioDescripción
urlsí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 -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"}'

La respuesta 201 contiene el endpoint:

CampoDescripción
webhook_idId del endpoint, que se usa para rotarlo o desactivarlo.
urlLa URL registrada.
secretEl 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_last4Los últimos cuatro caracteres del secreto, para distinguir unos secretos de otros.
statusactive, o disabled tras desactivarlo.
previous_secret_expires_atnull, o el final de la ventana de rotación; vea Rote el secreto.
rotated_at, created_at, updated_atMarcas 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/webhooks lista los endpoints del entorno de la clave, activos y desactivados, del más antiguo al más reciente. El listado muestra secret_last4 y nunca el secreto.
  • POST /v1/webhooks/{webhook_id}/rotate emite 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, responde 404.

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
  • t es el momento en que se firmó la entrega, en milisegundos Unix.
  • v1 es un HMAC-SHA256 codificado en hexadecimal, de 64 caracteres. Hay un v1 por 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 de t, un punto y el cuerpo de la solicitud exactamente como llegó. La clave es el secreto de su endpoint.

Para verificar una entrega:

  1. 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.
  2. Extraiga t y los valores v1 del encabezado. Rechace la solicitud si falta t o todos los v1, o están mal formados.
  3. Recházela si t difiere de su reloj en más de 5 minutos, en cualquier sentido. Así se evita reproducir más tarde una entrega capturada.
  4. Calcule el HMAC-SHA256 de <t>.<cuerpo sin procesar> con su secreto y compárelo con cada v1 mediante 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.

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.

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.

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
});

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 2xx en un plazo de 10 segundos. El cuerpo de la respuesta no se usa: un 204 vacío basta, y un cuerpo inusualmente grande cuenta como fallo.
  • Las redirecciones no se siguen. Una respuesta 3xx cuenta 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, 429 y todo 5xx. Un fallo temporal de DNS también se reintenta.
  • No se reintentan: cualquier otro 4xx y cualquier 3xx, 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 2xx se perdió. Conserve los valores de event_id ya 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.