Manejo de resultados
Hay dos formas de recibir un veredicto — polling y webhooks — y las dos corren en tu servidor.
El evento veridia:complete del widget no es ninguna de las dos. Se dispara en el navegador en el momento en que la API acepta el envío y lleva solo { verificationId, status }. Usalo para mostrar un spinner y para registrar a cuál de tus usuarios pertenece ese verificationId. El veredicto viene de tu backend.
| Método | Cuándo usarlo | Latencia | Notas |
|---|---|---|---|
| Polling | Prototipos, o cuando no tenés un endpoint HTTPS público | Segundos; vos controlás el intervalo | Simple, pero tenés que estar corriendo para ver el resultado |
| Webhooks | Producción | Segundos después de completarse | Con reintentos; además entrega veredictos que un revisor humano cambia más tarde |
Para producción, usá webhooks. Hay algo que el polling estructuralmente no te puede dar: cuando un caso en review es después aprobado o rechazado por una persona en el panel, esa decisión llega como webhook. Si hiciste polling una vez, viste review y paraste, nunca te vas a enterar del desenlace.
Leé esto antes de escribir cualquier lógica de ramificación
status y verdict son dos ejes distintos, y confundirlos es el error más caro que esta API permite.
| Campo | Pregunta que responde | Valores |
|---|---|---|
status | ¿Terminó de correr el pipeline? | queued, processing, completed, failed |
verdict | ¿La persona pasó? | approved, review, rejected |
status: "completed" significa que la maquinaria corrió hasta el final. No dice nada sobre si el solicitante es quien dice ser: una verificación rejected también está completed.
// MAL — esto da de alta a todos los solicitantes rechazados.
if (result.status === 'completed') {
enableUserAccount(userId);
}
// BIEN — status te dice que el resultado esta listo; verdict te dice cual es.
if (result.status === 'completed') {
onVerdict(result);
}
Tres trampas relacionadas:
verdictpuede llegar comonull. Mientras el pipeline corre, la clave está presente con valor nulo — no está ausente.'verdict' in dataes verdadero desde el primerísimo poll, así que no uses la presencia de la clave como señal de finalización. Ramificá sobrestatus.status: "failed"no tiene veredicto en absoluto. El pipeline dio error. Eso no es un rechazo; es la ausencia de un resultado. Manejalo aparte — normalmente pidiéndole al usuario que vuelva a correr el flujo.reviewes un veredicto final, no un estado transitorio. Hacer polling en un loop esperando quereviewse resuelva en otra cosa espera para siempre. Se resuelve cuando decide una persona, y eso te llega por webhook.
Método 1 — Polling de GET /v1/verify/{id}
Acá necesitás la clave secreta
Este endpoint requiere una clave de la familia secreta. Una clave publicable devuelve HTTP 401 con error: "secret_key_required".
Las claves secretas de modo test llevan el prefijo qv_sect_; las de live, qv_sec_. Si armaste los pasos 1 y 2 con una clave qv_pubt_, tu clave secreta correspondiente es qv_sect_..., de la misma sección API keys del panel. No existe una clave qv_sec_ en un entorno de test, y no existe la forma qv_pub_test_ para ninguno de los dos prefijos.
Tu clave publicable está visible en el código fuente de tu página. Si la lectura de veredictos la aceptara, cualquiera podría abrir las herramientas de desarrollo y sacar el resultado KYC de cualquier verificación. Para eso existe la restricción: mantené la clave secreta en tu servidor.
curl
curl https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2 \
-H "Authorization: Bearer qv_sect_TU_CLAVE_SECRETA_DE_TEST"
Mientras todavía está corriendo:
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "processing",
"verdict": null,
"confidence": null,
"scores": null,
"flags": null,
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": null
}
Fijate que todas las claves ya están ahí, con null. No falta nada mientras el pipeline corre — por eso la presencia de claves es inútil como test de finalización.
Una vez terminado:
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "completed",
"verdict": "approved",
"confidence": 91.4,
"scores": {
"ocr_confidence": 78.0,
"face_match": 96.2,
"liveness": 91.5,
"doc_quality": 85.0,
"mrz_valid": 100.0,
"name_match": 88.3
},
"flags": [
{ "level": "ok", "text": "auto_approved_all_checks_passed" }
],
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": "2026-05-01T18:39:08Z"
}
El objeto scores
Seis claves, todas en snake_case:
| Clave | Significado | Rango |
|---|---|---|
ocr_confidence | Qué tan bien se leyó el texto del documento | 0–100 |
face_match | La selfie contra la foto del documento | 0–100 |
liveness | Señal de prueba de vida | 0–100, o null |
doc_quality | Calidad de imagen del documento | 0–100 |
mrz_valid | Validez del checksum de la zona de lectura mecánica | 0–100 |
name_match | submitted-full-name contra el nombre del documento | 0–100 |
Dos modos de falla que conviene evitar:
Las claves no están en camelCase. scores.faceMatch es undefined. Eso falla de forma silenciosa y peligrosa: undefined < 80 evalúa a false, así que un chequeo de umbral como if (scores.faceMatch < 80) reject() nunca se dispara, y un control de seguridad que creés haber escrito queda permanentemente desactivado sin ningún error.
liveness puede ser null — cuando no hubo señal de prueba de vida, o cuando el paso de prueba de vida falló. Es el único puntaje que puede ser nulo, y es justamente el que la gente mete en cuentas aritméticas más seguido. Verificalo antes de usarlo:
const liveness = result.scores.liveness;
if (liveness !== null && liveness < 70) { /* ... */ }
El array flags
Una lista de objetos, no de strings: { level, text }. Hay exactamente tres niveles:
| Nivel | Significado |
|---|---|
ok | Un control pasó. Señal positiva |
warn | Se notó algo, pero no es descalificante |
err | Una falla dura |
ok es el que hace tropezar a la gente. Toda verificación aprobada lleva { "level": "ok", "text": "auto_approved_all_checks_passed" }, así que un resultado aprobado nunca tiene el array flags vacío. Si tratás "cualquier flag" como "un problema", vas a mandar el 100 % de tus aprobaciones a revisión manual porque el marcador de éxito parece una advertencia.
Filtrá por nivel, y tené en cuenta que el nivel de una falla dura genuina es err:
const hardFailures = result.flags.filter(f => f.level === 'err');
Entre los textos de flag que probablemente te importen están possible_screen_capture (una foto de una pantalla en vez de un documento), mrz_viz_mismatch (el MRZ no coincide con los datos impresos), active_liveness_spoof y aml_sanctions_match / aml_possible_match. Tratá el texto como un string opaco contra el cual comparás, no como algo para mostrarle al usuario final: decirle a un defraudador qué control lo agarró es regalarle retroalimentación para afinar el ataque.
JavaScript / Node.js
async function pollVerification(verificationId, maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
const response = await fetch(
`https://api.xxuxe.online/v1/verify/${verificationId}`,
{ headers: { Authorization: `Bearer ${process.env.VERIDIA_SECRET_KEY}` } }
);
if (!response.ok) {
const err = await response.json();
// secret_key_required significa que mandaste una clave publicable.
throw new Error(`${err.error}: ${err.message} (requestId ${err.requestId})`);
}
const data = await response.json();
// status habla del pipeline, no de la persona.
if (data.status === 'completed') return data;
if (data.status === 'failed') {
throw new Error(`Verification ${verificationId} failed to process`);
}
await new Promise(r => setTimeout(r, 1000));
}
throw new Error('Verification did not complete in time');
}
const result = await pollVerification('vf_AG07CDWRRFQV4T05ZXG2');
onVerdict(result); // ramifica sobre result.verdict, nunca sobre result.status
Python
import os
import time
import requests
def poll_verification(verification_id, max_attempts=30):
headers = {"Authorization": f"Bearer {os.environ['VERIDIA_SECRET_KEY']}"}
for _ in range(max_attempts):
r = requests.get(
f"https://api.xxuxe.online/v1/verify/{verification_id}",
headers=headers,
timeout=10,
)
r.raise_for_status()
data = r.json()
if data["status"] == "completed":
return data
if data["status"] == "failed":
raise RuntimeError(f"Verification {verification_id} failed to process")
time.sleep(1)
raise TimeoutError("Verification did not complete in time")
result = poll_verification("vf_AG07CDWRRFQV4T05ZXG2")
on_verdict(result["verdict"], result["scores"], result["flags"])
PHP
<?php
function pollVerification(string $verificationId, int $maxAttempts = 30): array {
$secretKey = $_ENV['VERIDIA_SECRET_KEY'];
for ($i = 0; $i < $maxAttempts; $i++) {
$ch = curl_init("https://api.xxuxe.online/v1/verify/$verificationId");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $secretKey"]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Chequeá el estado antes de confiar en el cuerpo. Sin esto, un 401 —
// fácil de provocar consultando con una clave publicable, que este
// endpoint rechaza — deja $data['status'] indefinido, el bucle agota
// sus 30 intentos y después reporta un timeout. El diagnóstico apuntaría
// a que el pipeline está lento cuando el problema real es la clave.
if ($httpCode === 401) {
throw new RuntimeException(
"401 al leer el veredicto. Este endpoint necesita una clave SECRETA "
. "(qv_sec_*); una publicable puede iniciar y enviar, pero no leer resultados."
);
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new RuntimeException("Veridia devolvió HTTP $httpCode: $response");
}
$data = json_decode($response, true);
if ($data['status'] === 'completed') {
return $data;
}
if ($data['status'] === 'failed') {
throw new RuntimeException("Verification $verificationId failed to process");
}
sleep(1);
}
throw new RuntimeException("Verification did not complete in time");
}
$result = pollVerification("vf_AG07CDWRRFQV4T05ZXG2");
onVerdict($result);
Un request por segundo durante treinta segundos queda cómodamente dentro del límite de este endpoint (600 requests por minuto por tenant), así que estos loops no te van a hacer chocar contra el límite de tasa.
Método 2 — Webhooks (recomendado para producción)
Configuración
Hay un webhook por tenant, configurado en tu panel bajo Settings → Webhook. Dos campos:
- URL — tu endpoint. Tiene que empezar con
https://; el panel rechaza cualquier otra cosa. Las direcciones privadas, de loopback, link-local y CGNAT también se rechazan. - Secreto — este valor lo elegís vos, mínimo 24 caracteres. No se genera por vos y después no se te vuelve a mostrar; el campo es de solo escritura y dejarlo en blanco mantiene el secreto actual. Generá algo aleatorio, guardalo en tu propio gestor de secretos y pegalo ahí.
Esa es toda la configuración. No hay lista de endpoints ni selección de eventos: recibís los tres tipos de evento o ninguno. La página Webhooks del panel es el historial de entregas, no un lugar para agregar endpoints.
Como la URL tiene que ser HTTPS y solo se aceptan direcciones públicas, no podés apuntar un webhook a http://localhost:3000. Usá ngrok, localtunnel o Cloudflare Tunnel y registrá la URL HTTPS pública que te dé. Esta es la única forma de probar webhooks localmente.
Qué recibís
El payload es plano — no hay envoltorio data:
{
"id": "evt_9f2c1b7a4e5d38c0a1b2c3d4e5f60718",
"type": "verification.review_required",
"createdAt": 1777663148,
"tenantId": "tn_default_demo",
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"verdict": "review",
"confidence": 77.85,
"userRef": "customer-12345",
"scores": {
"ocr_confidence": 20.0,
"face_match": 99.5,
"liveness": 88.0,
"doc_quality": 75.7,
"mrz_valid": 0.0,
"name_match": 61.2
},
"flags": [
{ "level": "warn", "text": "heavy_glare" }
],
"fieldsExtracted": {
"full_name": "MARIA GONZALEZ",
"document_number": "1234567",
"date_of_birth": "1990-04-12",
"nationality": "PRY",
"document_type": "dni"
},
"latencyMs": 2140
}
| Campo | Notas |
|---|---|
id | evt_ + hex. La clave de deduplicación. Estable entre reintentos del mismo evento |
type | El discriminador sobre el que ramificás. No es event |
createdAt | Tiempo Unix en segundos, un entero — no un string ISO |
tenantId, verificationId | Identificadores |
verdict, confidence | El resultado |
userRef | Lo que pasaste como user-ref. Puede ser null si nunca lo seteaste |
scores | Las mismas seis claves en snake_case de más arriba; liveness puede ser null |
flags | Los mismos objetos { level, text }, los mismos niveles ok / warn / err |
fieldsExtracted | Datos de identidad leídos del documento — mirá la advertencia de abajo |
latencyMs | Tiempo del pipeline. Es null cuando la decisión la tomó una persona |
El evento no incluye metadata, submittedAt ni completedAt. Si necesitás las marcas de tiempo del envío, leelas desde GET /v1/verify/{id}.
fieldsExtracted son datos personalesCada evento lleva el nombre completo, el número de documento y la fecha de nacimiento de la persona. Tu endpoint de webhook es, por lo tanto, un sistema que procesa datos de identidad, y todo lo que esté aguas abajo también. En particular: no registres el cuerpo crudo del request en un servicio de logging de propósito general, y no lo reenvíes a rastreadores de errores de terceros, sin haberlo decidido deliberadamente. La mayoría de los equipos descubre esto cuando los datos personales ya están en su índice de logs, donde borrarlos cuesta muchísimo más trabajo que no haberlos mandado nunca.
Los tres tipos de evento
Exactamente tres, y ninguno más:
verification.approvedverification.rejectedverification.review_required
No hay verification.created ni verification.expired. Igual manejá con elegancia los tipos desconocidos — pero no construyas lógica para tipos específicos que no existen.
switch (payload.type) { // `type`, no `event`
case 'verification.approved': return onApproved(payload);
case 'verification.rejected': return onRejected(payload);
case 'verification.review_required': return onReview(payload);
default:
console.warn('Unknown Veridia event type:', payload.type);
}
Verificar la firma
Cada entrega lleva dos headers:
Veridia-Signature: t=1777663148,v1=5f8c...e21
Veridia-Event: verification.review_required
El MAC es HMAC-SHA256 sobre los bytes "<t>." + rawBody, con tu secreto de webhook como clave. La marca de tiempo vive dentro del header de firma — no hay un header de timestamp aparte, ni una variante con prefijo X- de ninguno de los dos.
Verificá contra los bytes crudos del request. Parsear el JSON y volver a serializarlo cambia el digest y tu verificación va a fallar, por más correcto que sea el resto de tu código. En Express usá express.raw(), en Flask request.get_data(), en PHP php://input.
Una tolerancia de 300 segundos sobre t es la correcta y no deberías ampliarla. El despachador vuelve a firmar en cada reintento, así que el sexto intento — doce minutos después del primero — llega con un t fresco, no vencido. Ampliar la ventana no te da nada y debilita tu protección contra repetición.
Ejemplos completos y resueltos en cuatro lenguajes: verificación de firma.
Entrega y reintentos
La entrega es al menos una vez. Las entregas se encolan en un outbox transaccional y se reintentan ante falla con backoff de 1s, 5s, 30s, 2m, 10m — seis intentos a lo largo de unos 12,6 minutos. Después de eso la entrega queda estacionada como failed, y un operador puede volver a encolarla desde el panel.
La causa común de un duplicado no es un bug de ninguno de los dos lados: tu handler procesó el evento correctamente pero tardó más que el timeout en responder, así que el 200 nunca llegó y el despachador reintentó. Asumí que va a pasar.
Deduplicá por id. Es el mismo valor en cada reintento de un evento, y es distinto para cada evento — incluidos dos eventos sobre la misma verificación, que es exactamente el caso que una clave como verificationId + type resuelve mal. Una verificación que vuelve como review_required y después es aprobada por un revisor produce dos eventos para un mismo verificationId; deduplicá por cualquier cosa que no sea id y vas a descartar la decisión de la persona, dejando a ese usuario pendiente para siempre.
const seen = await db.webhookEvents.findUnique({ where: { id: payload.id } });
if (seen) return res.status(200).end(); // ya procesado
await db.webhookEvents.create({ data: { id: payload.id } });
Respondé 2xx rápido y hacé el trabajo real después — la entrega expira a los 10 segundos.
Actuar sobre el veredicto
async function onVerdict(payload) {
// Vengas del camino que vengas, primero mapea de vuelta a tu usuario.
// Desde un webhook: payload.userRef (si seteaste user-ref).
// Desde polling: tu propia tabla verificationId -> userId, guardada
// cuando el widget disparo veridia:complete.
const userId = await resolveUser(payload);
switch (payload.verdict) {
case 'approved':
await enableUserAccount(userId);
break;
case 'review':
// Final hasta que decida una persona. La decision llega como un segundo webhook.
await queueForReview(userId, payload.verificationId, payload.flags);
break;
case 'rejected':
await blockUserKyc(userId, payload.verificationId);
break;
default:
// verdict era null: el pipeline no termino. No actues.
console.error('No verdict yet for', payload.verificationId);
}
}
Qué sigue
Quickstart completo. Desde acá, según lo que estés construyendo:
- Documentación del widget — todos los atributos, eventos y opciones de estilo
- Referencia de la API — la API REST completa para integraciones desde el servidor y clientes propios
- Webhooks — verificación de firma, comportamiento de reintentos, ejemplos resueltos
- Cumplimiento — retención de datos y postura regulatoria
¿Necesitás ayuda? Contactá a soporte.