Saltar al contenido principal

Tipos de evento

Veridia emite tres tipos de evento, y los tres son resultados de verificación. Todos los eventos tienen la misma forma; solo cambian type, verdict y los valores.

typeverdictSe dispara cuando
verification.approvedapprovedLa confianza está en el umbral de aprobación o por encima, sin fallos duros, sin coincidencia en listas de sanciones
verification.rejectedrejectedLa confianza está por debajo del umbral de rechazo
verification.review_requiredreviewCualquier cosa intermedia, o cualquier fallo duro, o una coincidencia fuerte en listas de sanciones

No hay otros tipos. No existe verification.created, verification.expired ni verification.refunded. Igual deberías manejar un type desconocido sin dar error — logueálo y devolvé 2xx — pero no construyas lógica contra un nombre que no aparece en la tabla de arriba.

Cómo se decide el veredicto

Los umbrales son ajustes de entorno a nivel de todo el despliegue, no configuración por tenant. No hay una perilla que soporte pueda mover para tu cuenta.

ConfianzaVeredicto
>= 90approved
6089.99review
< 60rejected

Dos reglas pisan al score, y ambas existen para fallar hacia un humano en vez de hacia una aprobación:

  • Cualquier fallo duro nunca auto-aprueba. No se encontró cara en el documento o en la selfie, coincidencia facial por debajo del umbral crítico, un spoof de prueba de vida activa, un checksum de MRZ fallido, una imagen de documento inutilizable, o una MRZ que contradice los campos impresos del documento. Sin importar la confianza, el caso pasa a review — o a rejected si la confianza además está por debajo de 60.
  • Una coincidencia fuerte en listas de sanciones fuerza review. Degrada un caso que si no estaría aprobado, y nunca auto-rechaza. La identidad puede ser perfectamente válida; una persona tiene que resolverlo.

La consecuencia práctica: un caso que puntúa 87 es review, no approved. Si planificaste tu embudo asumiendo que "alta confianza significa onboarding automático", dimensioná la cola manual en consecuencia.

Campos comunes

Todos los eventos llevan exactamente estos campos.

CampoTipoSiempre presenteDescripción
idstringevt_<32 hex>. La clave de idempotencia — estable a lo largo de los reintentos de este evento, única entre eventos. También se manda como header Veridia-Event-Id.
typestringEl tipo de evento. Este es el campo sobre el que hacés el switch.
createdAtnumberTimestamp Unix en segundos (entero), de cuando el evento se encoló. No es ISO 8601.
tenantIdstringTu ID de tenant.
verificationIdstringEl ID que devuelve /v1/verify/init (vf_*).
envstring"live" o "test". Las claves de test y de live entregan a la MISMA URL, así que ramificá sobre esto antes de actuar — un verification.approved sintético de una corrida de QA nunca debe activar una cuenta real. Los eventos encolados antes del 2026-07-30 son previos al campo; tratá un valor ausente como "live".
verdictstringapproved, review o rejected.
confidencenumberScore ponderado global, 0–100.
userRefstring | nullEl userRef que pasaste a /init, o null si no pasaste ninguno.
scoresobjectDesglose por señal. Ver abajo.
flagsarrayObjetos de { level, text }. Ver abajo.
fieldsExtractedobjectDatos de identidad leídos del documento. PII. Ver abajo.
latencyMsnumber | nullTiempo de pipeline en milisegundos. null para eventos producidos por la decisión de un revisor humano, donde la latencia del pipeline no tendría sentido.

Campos que el payload no contiene, aunque parezcan plausibles: event, metadata, submittedAt, completedAt, status. El metadata que quizás mandaste a /v1/verify/submit se guarda con la verificación pero no se devuelve en el evento — userRef, definido en /init, es el único campo de correlación que vuelve.

El único timestamp es createdAt, en segundos. Si persistís una fecha de finalización de KYC, derivala de ahí:

const kycCompletedAt = new Date(payload.createdAt * 1000);

scores

Seis claves, todas en snake_case. El objeto tiene la misma forma en los tres tipos de evento.

ClaveRangoSignificado
ocr_confidence0–100La confianza propia del modelo de extracción en el texto del documento que leyó
face_match0–100Similitud biométrica entre la selfie y la foto del documento
liveness0–100 o nullAnti-spoofing pasivo sobre la selfie. null cuando no hubo señal disponible o el modelo dio error
doc_quality0–100Nitidez, reflejos, moiré y resolución de la imagen del documento
mrz_valid0–100Validez de los checksums de la zona de lectura mecánica (MRZ)
name_match0–100Coincidencia difusa entre submittedFullName y el nombre leído del documento

liveness es el único miembro nulleable, y es justo aquel sobre el que los integradores más suelen poner umbrales. Protegelo:

const liveness = payload.scores.liveness;
if (liveness !== null && liveness < 50) {
// tratar como señal débil
}

Un liveness ausente no es un liveness aprobado. Si tu política de riesgo depende de él, tratá null como "desconocido" y enrutá según eso, en vez de asignarle un número por defecto.

confidence es una combinación ponderada de estas señales, acotada por las reglas de fallo duro de arriba. No es el promedio.

flags

Un array de objetos — no de strings:

"flags": [
{ "level": "warn", "text": "heavy_glare" },
{ "level": "err", "text": "mrz_viz_mismatch" }
]

Niveles

Hay tres, y ok es el que sorprende a la gente.

levelSignificado
okUn chequeo pasó. Informativo, positivo.
warnUn problema blando. Contribuye a bajar la confianza.
errUn problema duro. Impide la auto-aprobación de plano.

No existe info ni critical. De ahí se desprenden dos modos de falla:

  • Filtrar por level === 'critical' no matchea nada, así que todos los casos caen en tu cola con prioridad normal — incluidos los que tienen fallos duros, que son exactamente los que un revisor debería ver primero. Filtrá por 'err'.
  • Tratar un array flags no vacío como "algo anda mal" dispara alarmas en los casos exitosos. Toda verificación auto-aprobada lleva { "level": "ok", "text": "auto_approved_all_checks_passed" } como primera flag. Un evento aprobado nunca es flags: [].
const hasHardFailure = payload.flags.some(f => f.level === 'err');
const problems = payload.flags.filter(f => f.level !== 'ok');

Valores de flag

Valores de text que el pipeline emite hoy:

textNivelSignificado
auto_approved_all_checks_passedokTodo pasó; presente en toda auto-aprobación
mrz_checksums_validokChecksums de MRZ verificados
mrz_viz_consistentokLa MRZ concuerda con los campos impresos del documento
active_liveness_liveokSe superó el reto de prueba de vida activa
image_blurrywarnImagen del documento demasiado borrosa para leerla con fiabilidad
heavy_glarewarnReflejos que tapan el documento
possible_screen_capturewarnPatrón de moiré — el "documento" puede ser una foto de una pantalla
low_resolutionwarnImagen del documento por debajo de la resolución utilizable
mrz_checksum_failedwarnHay MRZ pero los checksums no validan
aml_possible_matchwarnCoincidencia blanda contra una lista de sanciones
missing_imageserrFaltaban imágenes requeridas al momento de procesar
no_face_detected_on_documenterrNo se encontró cara en la foto del documento
no_face_detected_on_selfieerrNo se encontró cara en la selfie
face_match_below_critical_thresholderrLa selfie y la foto del documento están muy lejos entre sí
active_liveness_spooferrEl reto de prueba de vida activa indica un replay o una superficie plana
document_quality_unusableerrImagen del documento inutilizable para verificar
mrz_viz_mismatcherrUna MRZ con checksum válido contradice los campos impresos — señal de falsificación
aml_sanctions_matcherrCoincidencia fuerte contra una lista de sanciones; fuerza review, nunca auto-rechaza

Dos notas más. Los errores de comparación facial se exponen como flags err cuyo text es el string del error subyacente, así que tomá esta lista como el conjunto de valores conocidos y no como un enum cerrado — matcheá lo que reconozcas y pasá el resto tal cual a tus revisores. Y las dos flags de AML son las que tienen peso regulatorio: aml_sanctions_match significa que una persona tiene que resolver el caso antes del onboarding, por más buena que haya sido la biometría.

No le muestres las flags al usuario final

Decirle a alguien qué señal lo detectó es un tutorial gratis para el próximo intento. Mostrá un genérico "no pudimos verificar tu documento, por favor contactá a soporte" y guardá flags para tu cola de revisión interna.

fieldsExtracted

Los datos de identidad leídos del documento.

ClaveEjemplo
full_name"MARIA ELENA GONZALEZ"
document_number"4567890"
date_of_birth"1991-04-17"
nationality"PRY"
document_type"dni"

Cualquier valor puede ser null — el pipeline reporta lo que pudo leer. Son salidas de OCR y MRZ, no afirmaciones que Veridia haga sobre la persona. nationality en particular se lee del documento y en tráfico real falta o está mal con frecuencia; no la uses para manejar lógica importante sin corroboración.

Este objeto son datos personales. Tu endpoint tiene que ser HTTPS (el panel lo exige), y si logueás los cuerpos crudos, tus logs ahora guardan documentos de identidad.

Ejemplos

verification.approved

{
"id": "evt_9f2c41ab7d8e4c05b6a3e17f2d904c8b",
"type": "verification.approved",
"createdAt": 1753142348,
"tenantId": "tn_default_demo",
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"verdict": "approved",
"confidence": 93.1,
"userRef": "customer-12345",
"scores": {
"ocr_confidence": 78.0,
"face_match": 96.2,
"liveness": 91.5,
"doc_quality": 85.0,
"mrz_valid": 100.0,
"name_match": 88.0
},
"flags": [
{ "level": "ok", "text": "auto_approved_all_checks_passed" },
{ "level": "ok", "text": "mrz_checksums_valid" }
],
"fieldsExtracted": {
"full_name": "MARIA ELENA GONZALEZ",
"document_number": "4567890",
"date_of_birth": "1991-04-17",
"nationality": "PRY",
"document_type": "dni"
},
"latencyMs": 3184
}
case 'verification.approved':
await db.users.update(payload.userRef, {
kycStatus: 'verified',
kycCompletedAt: new Date(payload.createdAt * 1000),
kycVerificationId: payload.verificationId,
});
await sendWelcomeEmail(payload.userRef);
break;

verification.rejected

{
"id": "evt_3b71dd90c4a24f6ea5c0812fb7e39d14",
"type": "verification.rejected",
"createdAt": 1753145532,
"tenantId": "tn_default_demo",
"verificationId": "vf_BX18DEXSGFRX5U16YH3Q",
"verdict": "rejected",
"confidence": 42.1,
"userRef": "customer-67890",
"scores": {
"ocr_confidence": 65.0,
"face_match": 31.4,
"liveness": 88.0,
"doc_quality": 50.5,
"mrz_valid": 0.0,
"name_match": 44.0
},
"flags": [
{ "level": "err", "text": "face_match_below_critical_threshold" },
{ "level": "warn", "text": "image_blurry" }
],
"fieldsExtracted": {
"full_name": "J. PEREZ",
"document_number": null,
"date_of_birth": null,
"nationality": null,
"document_type": "dni"
},
"latencyMs": 2971
}
case 'verification.rejected':
await db.users.update(payload.userRef, {
kycStatus: 'rejected',
kycRejectionFlags: payload.flags, // solo uso interno
});
await sendGenericFailureEmail(payload.userRef);
break;

verification.review_required

{
"id": "evt_c05e8a1746bf4d92ae37b6c2019df8aa",
"type": "verification.review_required",
"createdAt": 1753149933,
"tenantId": "tn_default_demo",
"verificationId": "vf_CY29EFYTGFSZ6V27ZH4R",
"verdict": "review",
"confidence": 87.3,
"userRef": "customer-11111",
"scores": {
"ocr_confidence": 72.0,
"face_match": 79.5,
"liveness": null,
"doc_quality": 45.0,
"mrz_valid": 100.0,
"name_match": 91.0
},
"flags": [
{ "level": "warn", "text": "heavy_glare" },
{ "level": "err", "text": "document_quality_unusable" }
],
"fieldsExtracted": {
"full_name": "CARLOS ALBERTO RIVAS",
"document_number": "3312004",
"date_of_birth": "1988-11-02",
"nationality": null,
"document_type": "dni"
},
"latencyMs": 3402
}

Fijate en la forma de este: la confianza es 87,3 — por encima de lo que un integrador podría suponer que es una aprobación — y liveness es null. Lo que lo mandó a revisión fue la flag err.

case 'verification.review_required':
await db.users.update(payload.userRef, { kycStatus: 'pending_review' });
await reviewQueue.add({
verificationId: payload.verificationId,
userRef: payload.userRef,
flags: payload.flags,
priority: payload.flags.some(f => f.level === 'err') ? 'high' : 'normal',
});
break;

review es terminal hasta que una persona actúe sobre él. No llega ningún evento más por sí solo. Cuando un revisor decide el caso en el panel de Veridia, recibís un segundo evento — verification.approved o verification.rejected — para el mismo verificationId, con un id nuevo. Tu handler tiene que aplicarlo. Por eso la clave de deduplicación tiene que ser id y no algo derivado de verificationId.

Routing

async function handleVeridiaWebhook(payload) {
switch (payload.type) {
case 'verification.approved':
return handleApproved(payload);

case 'verification.rejected':
return handleRejected(payload);

case 'verification.review_required':
return handleReviewRequired(payload);

default:
// Tipo desconocido: logueá y devolvé éxito. Nunca lances una excepción —
// acá se convierte en un 5xx, y el evento se reintenta por ~12,6 minutos.
logger.warn('Unknown Veridia event type', {
type: payload.type,
eventId: payload.id,
verificationId: payload.verificationId,
});
}
}

Qué sigue