Webhooks firmados
FINORA KYC te avisa de lo que pasa en tus casos con un POST firmado a los destinos que registres. Cada destino elige sus eventos y tiene su propio secreto.
Registrar un destino
curl -s -X POST "$KYC_BASE/v1/webhooks" \
-H "X-API-Key: $KYC_API_KEY" -H "Idempotency-Key: wh-principal" \
-H "Content-Type: application/json" \
-d '{
"url": "https://mi-empresa.example/kyc/eventos",
"eventos": ["caso.decidido", "caso.en_revision", "alerta.creada", "monitoreo.coincidencia_nueva"],
"descripcion": "Sistema central"
}'
{
"id": "whk_01J9Z3K8Q2W7XPT4M6N5R0B1CK",
"url": "https://mi-empresa.example/kyc/eventos",
"eventos": ["alerta.creada", "caso.decidido", "caso.en_revision", "monitoreo.coincidencia_nueva"],
"activo": true,
"creadoEn": "2026-10-15T13:00:00Z",
"secreto": "whsec_…"
}
httpsobligatorio. En sandbox también se aceptahttpy destinos en tu red local, para probar en tu equipo.- Fuera de sandbox, los destinos que resuelven a direcciones locales o privadas se rechazan al enviar.
- Máximo 20 destinos por cuenta.
- El
secretose muestra una sola vez.
Prueba el destino con un evento webhook.prueba:
curl -s -X POST "$KYC_BASE/v1/webhooks/$WEBHOOK/probar" \
-H "X-API-Key: $KYC_API_KEY" -H "Idempotency-Key: wh-prueba-1"
# → 202 { "evento": "evt_01J9Z3K8Q2W7XPT4M6N5R0B1CM" }
Eventos
| Evento | Cuándo | datos |
|---|---|---|
caso.creado | Se creó un caso. | casoId, estado, referenciaExterna, flujo, solicitanteId |
caso.documento.procesado | Terminó la lectura de un documento. | casoId, documentoId, tipo, verificaciones[] (tipo, estado, resultado) |
caso.en_revision | El caso entró a la cola del oficial. | casoId, estado, referenciaExterna |
caso.decidido | Una persona aprobó o rechazó el caso. | casoId, estado (APROBADO o RECHAZADO), referenciaExterna, decididoPor, decididoEn |
caso.expirado | El caso venció sin completarse. | casoId, estado, referenciaExterna |
screening.completado | Terminó un screening. | Ver la referencia. |
alerta.creada, alerta.resuelta | Se abrió o se cerró una alerta de screening. | Ver la referencia. |
monitoreo.coincidencia_nueva | Una versión nueva de una lista produjo una coincidencia. | |
lista.actualizada | Se actualizó una lista. | |
ia.presupuesto | El consumo de IA del mes cruzó el umbral de aviso o se agotó. | mes, tipo (AVISO o AGOTADO), porcentaje, gastadoUsd, limiteUsd |
webhook.prueba | Prueba manual del destino. |
caso.biometria.procesada se acepta al suscribirse, pero se emitirá cuando lleguen la comparación facial y la prueba de vida.
Los webhooks nunca llevan imágenes ni números de documento completos: si necesitas más detalle, consulta el caso con tu credencial.
Qué llega
POST /kyc/eventos HTTP/1.1
Content-Type: application/json
X-Finora-Signature: t=1760533920,v1=4c1f…
X-Finora-Event-Id: evt_01J9Z3K8Q2W7XPT4M6N5R0B1CM
X-Finora-Evento: caso.decidido
X-Finora-Entrega: ent_01J9Z3K8Q2W7XPT4M6N5R0B1CN
{"id":"evt_01J9Z3K8Q2W7XPT4M6N5R0B1CM","tipo":"caso.decidido","creadoEn":"2026-10-15T15:12:00Z",
"objeto":{"tipo":"caso","id":"cas_01J9Z3K8Q2W7XPT4M6N5R0B1CD"},"sandbox":false,
"datos":{"casoId":"cas_01J9Z3K8Q2W7XPT4M6N5R0B1CD","estado":"APROBADO","referenciaExterna":"cliente-1001",
"decididoPor":"HUMANO","decididoEn":"2026-10-15T15:12:00Z"}}
| Cabecera | Para |
|---|---|
X-Finora-Signature | t=<epoch>,v1=<hex>. Verifícala antes de leer el cuerpo. |
X-Finora-Event-Id | Id del evento. Deduplica por este valor. |
X-Finora-Evento | Tipo del evento. |
X-Finora-Entrega | Id de la entrega; se repite en cada reintento de esa entrega. |
X-Finora-Sandbox | true en las entregas del sandbox. |
Verificar la firma
v1 = hex(HMAC-SHA256(secreto, t + "." + cuerpo)), sobre el cuerpo crudo, tal como llegó. Es el mismo esquema que el checkout del Merchant, sin v0.
- Calcula sobre los bytes recibidos, nunca sobre el JSON reparseado.
- Compara en tiempo constante.
- Rechaza si
|ahora − t| > 300 s. - Durante una rotación de secreto llega un
v1por cada secreto vigente: acepta si alguno coincide.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
export function verificarFirma(cabecera: string | undefined, cuerpoCrudo: Buffer, secreto: string,
ahoraS = Math.floor(Date.now() / 1000)): boolean {
if (typeof cabecera !== "string") return false;
let t = NaN;
const firmas: string[] = [];
for (const parte of cabecera.split(",")) {
const [k, v] = parte.trim().split("=", 2);
if (k === "t") t = Number(v);
else if (k === "v1" && v) firmas.push(v);
}
if (!Number.isFinite(t) || firmas.length === 0 || Math.abs(ahoraS - t) > 300) return false;
const esperada = Buffer.from(createHmac("sha256", secreto).update(`${t}.`).update(cuerpoCrudo).digest("hex"));
return firmas.some((f) => {
const recibida = Buffer.from(f);
return recibida.length === esperada.length && timingSafeEqual(recibida, esperada);
});
}
const app = express();
app.post("/kyc/eventos", express.raw({ type: "*/*" }), (req, res) => {
if (!verificarFirma(req.get("x-finora-signature"), req.body, process.env.KYC_WEBHOOK_SECRETO!)) {
return res.status(401).end();
}
const eventoId = req.get("x-finora-event-id");
if (yaProcesado(eventoId)) return res.status(200).end(); // deduplicar por evento
const evento = JSON.parse(req.body.toString("utf8")); // { id, tipo, creadoEn, objeto, sandbox, datos }
encolar(evento); // responde 2xx en menos de 10 s
res.status(200).end();
});
import hashlib, hmac, os, time
from flask import Flask, request, abort
def firma_valida(secreto: str, cuerpo: bytes, cabecera: str) -> bool:
t, firmas = None, []
for parte in (cabecera or "").split(","):
k, _, v = parte.strip().partition("=")
if k == "t":
t = v
elif k == "v1" and v:
firmas.append(v)
if not t or not firmas or abs(time.time() - int(t)) > 300:
return False
esperada = hmac.new(secreto.encode(), f"{t}.".encode() + cuerpo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperada, f) for f in firmas)
app = Flask(__name__)
@app.post("/kyc/eventos")
def eventos():
if not firma_valida(os.environ["KYC_WEBHOOK_SECRETO"], request.get_data(),
request.headers.get("X-Finora-Signature", "")):
abort(401)
evento = request.get_json(force=True) # después de verificar, nunca antes
return "", 200
# $T = valor de t; cuerpo.json = el cuerpo crudo recibido
printf '%s.' "$T" | cat - cuerpo.json \
| openssl dgst -sha256 -hmac "$KYC_WEBHOOK_SECRETO" -hex | awk '{print $NF}'
# debe ser igual a alguno de los v1 de la cabecera
Vector de prueba: secreto key, cuerpo The quick brown fox jumps over the lazy dog, t=1700000000 → v1 = 2f658d6aef4f246e91cd741bbcded7479e9605f9d41c9e248122a117e0e1765b.
Reintentos y bitácora
Responde 2xx en menos de 10 segundos; cualquier otro estado o un tiempo de espera cuenta como fallo. Los reintentos llegan a 1, 5, 25, 125, 360 y 720 minutos (siete intentos en total).
# Bitácora del destino, con cada intento (estado HTTP, duración y error)
curl -s "$KYC_BASE/v1/webhooks/$WEBHOOK/entregas?limite=20" -H "X-API-Key: $KYC_API_KEY"
# Reenviar un evento a todos los destinos activos suscritos a su tipo
curl -s -X POST "$KYC_BASE/v1/eventos/$EVENTO/reenviar" \
-H "X-API-Key: $KYC_API_KEY" -H "Idempotency-Key: reenvio-$EVENTO-1"
# → 202 { "entregas": ["ent_…"] }
Un reenvío crea una entrega nueva (otro X-Finora-Entrega) con el mismo X-Finora-Event-Id: tu deduplicación por evento lo absorbe.
Conciliar eventos perdidos
Si tu servidor estuvo caído más allá de los reintentos, recorre el registro de eventos desde la última vez que procesaste uno:
curl -s "$KYC_BASE/v1/eventos?tipo=caso.decidido&desde=2026-10-15T00:00:00Z&limite=200" \
-H "X-API-Key: $KYC_API_KEY"
La respuesta es una página { "datos": [ … ], "siguiente": "<cursor>" }, del más reciente al más antiguo. Pasa cursor=<siguiente> para la página siguiente.
Rotar el secreto sin corte
curl -s -X POST "$KYC_BASE/v1/webhooks/$WEBHOOK/rotar-secreto" \
-H "X-API-Key: $KYC_API_KEY" -H "Idempotency-Key: rotar-wh-1"
Durante 24 horas cada entrega lleva dos v1: uno con el secreto nuevo y otro con el anterior (secretoAnteriorVenceEn dice hasta cuándo). Despliega el secreto nuevo en tu servidor dentro de ese plazo.