Recepción de Resultados
El desenlace de una verificación le llega por uno de tres canales. Los tres están firmados con su webhookSecret, y GET /v1/sessions/{sessionId} es siempre el registro autoritativo detrás de ellos.
| Canal | Cuándo aplica |
|---|---|
| Webhook | delivery.mode es webhook (el predeterminado) |
| Redirección firmada | delivery.mode es redirect |
| Notificación de resultado | El resultado de una sesión de redirección, un resultado servidor a servidor que cambia después, o cualquier decisión tomada a posteriori |
Modo Webhook
Inyo envía por POST el resultado normalizado a su webhookUrl configurada:
POST /kyc-result HTTP/1.1 Content-Type: application/json X-Inyo-Signature: t=1704829200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verifique la Firma
Este es el mismo formato de encabezado que usa la API de Remesas, así que un solo verificador sirve para ambos productos.
| Componente | Significado |
|---|---|
t=<segundos> | Timestamp Unix de cuándo se calculó la firma — úselo para rechazar replays fuera de una ventana de tolerancia |
v1=<hex> | HMAC-SHA256 en hexadecimal, con su webhookSecret como clave. v1 es la versión actual del esquema |
La secuencia de bytes firmada es la concatenación ASCII:
<timestamp> + "." + <cuerpo crudo de la solicitud>
Use los dígitos de t= exactamente como aparecen — no los reparse ni los reformatee.
Verifique contra los bytes crudos, antes de parsear el JSON. Parsear y re-serializar cambia los espacios en blanco y el orden de las claves, lo que cambia los bytes, lo que rompe la firma. Este es el fallo de integración más común. Compare en tiempo constante — nunca con
==sobre cadenas.
Tres reglas que le ahorrarán una migración más adelante:
- Divida el encabezado por
,y busque su propia versión. Un esquema futuro se entregará comot=…,v1=…,v2=…durante una ventana de transición, así que un verificador que buscav1sigue funcionando mientras usted migra. Un verificador que asume que el encabezado es un solo valor se rompe en la coma. - Rechace un timestamp fuera de su tolerancia. 300 segundos es una ventana razonable. Sin esa comprobación el timestamp es decoración y una entrega capturada se puede reproducir para siempre.
- Rechace un encabezado que repita una versión. Nunca enviamos uno así, pero HTTP permite que los proxies unan encabezados duplicados con comas — y como este formato está delimitado por comas, elegir uno en silencio haría que la verificación dependiera del orden.
Node.js (Express):
import express from "express";
import crypto from "node:crypto";
const app = express();
// cuerpo crudo, no express.json()
app.post(
"/kyc-result",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Inyo-Signature") ?? "";
// divide la lista y rechaza una clave repetida en vez de elegir una
const parts = new Map();
for (const entry of header.split(",")) {
const [key, value] = entry.trim().split("=");
if (!key || !value) continue;
if (parts.has(key)) return res.sendStatus(401);
parts.set(key, value);
}
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (!timestamp || !signature) return res.sendStatus(401);
// rechaza replays: sin esto el timestamp no sirve de nada
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac("sha256", process.env.KYC_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
const signatureBuf = Buffer.from(signature, "utf8");
if (
expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)
) {
return res.sendStatus(401);
}
const result = JSON.parse(req.body.toString("utf8"));
handleResult(result);
res.sendStatus(200);
}
);
Python (FastAPI):
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
TOLERANCE_SECONDS = 300
def parse_signature(header: str) -> dict[str, str]:
"""Devuelve {} cuando una clave se repite, en vez de elegir una en silencio."""
parts: dict[str, str] = {}
for entry in header.split(","):
key, _, value = entry.strip().partition("=")
if not key or not value:
continue
if key in parts:
return {}
parts[key] = value
return parts
@app.post("/kyc-result")
async def kyc_result(request: Request, x_inyo_signature: str = Header(default="")):
raw = await request.body()
parts = parse_signature(x_inyo_signature)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
raise HTTPException(status_code=401, detail="invalid signature")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(status_code=401, detail="signature timestamp outside tolerance")
expected = hmac.new(
os.environ["KYC_WEBHOOK_SECRET"].encode(),
f"{timestamp}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="invalid signature")
handle_result(json.loads(raw))
return {"ok": True}
crypto.timingSafeEqual lanza una excepción si las longitudes no coinciden, por eso el ejemplo de Node comprueba la longitud primero; hmac.compare_digest lo maneja internamente.
Rotación de su Webhook Secret
Un secret puede rotarse — hable con su contacto en Inyo. La rotación toma efecto de inmediato y no es gradual: las entregas firmadas con el secret anterior se detienen en el momento en que se emite el nuevo, así que prevea una ventana corta en la que su verificador acepte cualquiera de los dos valores, y descarte el antiguo cuando deje de verlo. La rotación también invalida cualquier URL de redirección ya emitida para una sesión en curso, porque la firma se calculó con el secret retirado. Rote entre recorridos de clientes siempre que pueda.
Garantías de Entrega
| Propiedad | Comportamiento |
|---|---|
| Confirmación | Cualquier respuesta 2xx. Cualquier otra cosa cuenta como fallo |
| Reintentos | Hasta 3 intentos con backoff (aproximadamente 1 s, 4 s, 10 s) |
| Después de 3 fallos | La entrega se descarta y no se reintenta más adelante |
| Tiempo de espera | 15 segundos por intento |
La entrega es de mejor esfuerzo. Responda 2xx rápidamente y procese de forma asíncrona — un handler lento consume el tiempo de espera y convierte una verificación exitosa en una entrega descartada. Si necesita una garantía en lugar de un push, sondee GET /v1/sessions/{sessionId}.
Haga su handler idempotente. El resultado de la misma sesión puede llegar legítimamente más de una vez: un reintento después de que su 2xx se perdió en tránsito, o un desenlace genuinamente más nuevo (vea ordenamiento).
Modo Redirección
Cuando la sesión se creó con delivery.mode: "redirect", el cliente es devuelto a su redirectUrl con el desenlace en la cadena de consulta:
https://you.example/kyc-done?sessionId=9f1c8e42…&status=approved&sig=7b3e1d…&sigVersion=v1
| Parámetro | Descripción |
|---|---|
sessionId | La sesión que se completó |
status | approved, declined, expired o in_review |
sig | HMAC-SHA256 en hexadecimal de la cadena "<sessionId>.<status>", con su webhookSecret como clave |
sigVersion | El esquema con el que se calculó sig — actualmente siempre v1 |
La redirección no se firma como el encabezado del webhook. sig es un digest hexadecimal puro de 64 caracteres, sin timestamp, y la versión viaja como parámetro propio en vez de dentro del valor. Los dos difieren porque una redirección es una URL que sigue el navegador de su cliente, no un cuerpo de solicitud que nosotros controlamos: mantener sig puro hace que el verificador que ya escribió siga funcionando, y sigVersion le da a un esquema futuro dónde anunciarse sin un día de corte.
Verifique la Firma de la Redirección
import hashlib
import hmac
def valid_redirect(session_id: str, status: str, sig: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), f"{session_id}.{status}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
Lea sigVersion y rechace un valor que su código no implementa — eso es lo que lo hace útil. Tratar una versión desconocida como v1 verificaría un digest futuro contra el esquema equivocado y fallaría de una forma que parece manipulación. No hay timestamp que comprobar, así que acote el aterrizaje usted mismo: una redirección solo tiene sentido para una sesión que está esperando en ese momento.
Como status está dentro de la firma, un cliente no puede reescribir declined a approved, y una sesión retenida no puede reproducirse como una aprobación. Una firma que no se verifica significa que los parámetros fueron manipulados — rechace el aterrizaje por completo.
El status en una Redirección Puede Ser in_review
Una sesión retenida devuelve igualmente a su cliente a usted, en lugar de dejarlo varado en una pantalla de "en revisión". Su página de aterrizaje debe manejar in_review como un estado pendiente — no como un fallo, y no como una aprobación. La decisión final del analista no estará en la redirección con la que el cliente llegó; le llega a través de una notificación de resultado o mediante sondeo.
Notificaciones de Resultado
Una redirección viaja de vuelta en el navegador de su cliente, y una verificación servidor a servidor responde en un cuerpo de respuesta. Ninguna de las dos sobrevive a un cambio posterior — y los desenlaces cambian, cuando un analista decide una sesión en cola o una decisión de control de calidad revierte una completada.
Así que cuando tiene un webhookUrl configurado, los resultados también se envían por POST a él — firmados exactamente igual que la entrega en modo webhook — para las sesiones cuya entrega de cara al cliente es otra:
| Situación | Qué recibe |
|---|---|
| Se completa una sesión en modo redirección | El resultado inicial, incluyendo un in_review no terminal que la redirección misma no puede transportar |
| Cualquier cambio posterior | El resultado actualizado, con result.manualReview |
La respuesta original de POST /v1/verifications | Nada — el 201 fue la entrega |
Las sesiones en modo webhook no se ven afectadas: ahí el webhook es la entrega, no una notificación sobre ella.
Ordenamiento de Notificaciones Concurrentes
Cada notificación lleva una marca de tiempo notifiedAt:
{
"sessionId": "9f1c8e42…",
"status": "approved",
"autoStatus": "declined",
"manualReview": { "action": "review", "reviewer": "analyst-7", "reason": "…", "autoStatus": "declined" },
"notifiedAt": "2026-07-31T14:41:09.882Z"
}
Una sesión puede tener dos entregas en vuelo a la vez — una todavía reintentándose mientras se despacha una decisión más nueva — así que pueden llegar fuera de orden.
Aplique el
notifiedAtmás alto e ignore cualquier cosa más antigua. No confíe en el orden de llegada.
notifiedAt sella la entrega, no el resultado almacenado, por lo que está ausente del resultado que lee vía GET /v1/sessions/{sessionId} — ese endpoint siempre devuelve el estado actual, así que no hay nada que ordenar.
Cómo Desactivarlas
Las notificaciones de resultado están activadas por defecto. Pueden desactivarse para su tenant, dejando la redirección del navegador o la respuesta síncrona como su único canal — consulte a su contacto en Inyo. Habilitar las notificaciones sin un webhookUrl es rechazado en lugar de ignorado silenciosamente.
Elección de una Estrategia
| Su situación | Enfoque recomendado |
|---|---|
| Flujo de onboarding del lado del servidor | Modo webhook, con verificación de firma y un handler idempotente |
| Flujo web donde el cliente debe continuar de inmediato | Modo redirección para el cliente, más notificaciones como registro de la verdad |
| UI de captura propia | Servidor a servidor, más un webhookUrl para que las decisiones posteriores le lleguen |
| Crítico para cumplimiento, no puede perder un desenlace | Cualquiera de los anteriores más un trabajo de conciliación que sondee GET /v1/sessions/{sessionId} para las sesiones sin un estado terminal |
Próximos Pasos
- Comprobaciones y Decisiones — lectura del payload que acaba de verificar
- Revisión Manual — qué significan
in_reviewymanualReview
