Saltar al contenido principal

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_…"
}
  • https obligatorio. En sandbox también se acepta http y 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 secreto se 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​

EventoCuándodatos
caso.creadoSe creó un caso.casoId, estado, referenciaExterna, flujo, solicitanteId
caso.documento.procesadoTerminó la lectura de un documento.casoId, documentoId, tipo, verificaciones[] (tipo, estado, resultado)
caso.en_revisionEl caso entró a la cola del oficial.casoId, estado, referenciaExterna
caso.decididoUna persona aprobó o rechazó el caso.casoId, estado (APROBADO o RECHAZADO), referenciaExterna, decididoPor, decididoEn
caso.expiradoEl caso venció sin completarse.casoId, estado, referenciaExterna
screening.completadoTerminó un screening.Ver la referencia.
alerta.creada, alerta.resueltaSe abrió o se cerró una alerta de screening.Ver la referencia.
monitoreo.coincidencia_nuevaUna versión nueva de una lista produjo una coincidencia.
lista.actualizadaSe actualizó una lista.
ia.presupuestoEl consumo de IA del mes cruzó el umbral de aviso o se agotó.mes, tipo (AVISO o AGOTADO), porcentaje, gastadoUsd, limiteUsd
webhook.pruebaPrueba manual del destino.
Próximamente

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"}}
CabeceraPara
X-Finora-Signaturet=<epoch>,v1=<hex>. Verifícala antes de leer el cuerpo.
X-Finora-Event-IdId del evento. Deduplica por este valor.
X-Finora-EventoTipo del evento.
X-Finora-EntregaId de la entrega; se repite en cada reintento de esa entrega.
X-Finora-Sandboxtrue 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.

  1. Calcula sobre los bytes recibidos, nunca sobre el JSON reparseado.
  2. Compara en tiempo constante.
  3. Rechaza si |ahora − t| > 300 s.
  4. Durante una rotación de secreto llega un v1 por cada secreto vigente: acepta si alguno coincide.
Node.js (TypeScript)
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();
});
Python
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
openssl
# $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.