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.
type | verdict | Se dispara cuando |
|---|---|---|
verification.approved | approved | La confianza está en el umbral de aprobación o por encima, sin fallos duros, sin coincidencia en listas de sanciones |
verification.rejected | rejected | La confianza está por debajo del umbral de rechazo |
verification.review_required | review | Cualquier 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.
| Confianza | Veredicto |
|---|---|
>= 90 | approved |
60 – 89.99 | review |
< 60 | rejected |
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 arejectedsi 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.
| Campo | Tipo | Siempre presente | Descripción |
|---|---|---|---|
id | string | Sí | evt_<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. |
type | string | Sí | El tipo de evento. Este es el campo sobre el que hacés el switch. |
createdAt | number | Sí | Timestamp Unix en segundos (entero), de cuando el evento se encoló. No es ISO 8601. |
tenantId | string | Sí | Tu ID de tenant. |
verificationId | string | Sí | El ID que devuelve /v1/verify/init (vf_*). |
env | string | Sí | "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". |
verdict | string | Sí | approved, review o rejected. |
confidence | number | Sí | Score ponderado global, 0–100. |
userRef | string | null | Sí | El userRef que pasaste a /init, o null si no pasaste ninguno. |
scores | object | Sí | Desglose por señal. Ver abajo. |
flags | array | Sí | Objetos de { level, text }. Ver abajo. |
fieldsExtracted | object | Sí | Datos de identidad leídos del documento. PII. Ver abajo. |
latencyMs | number | null | Sí | Tiempo 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.
| Clave | Rango | Significado |
|---|---|---|
ocr_confidence | 0–100 | La confianza propia del modelo de extracción en el texto del documento que leyó |
face_match | 0–100 | Similitud biométrica entre la selfie y la foto del documento |
liveness | 0–100 o null | Anti-spoofing pasivo sobre la selfie. null cuando no hubo señal disponible o el modelo dio error |
doc_quality | 0–100 | Nitidez, reflejos, moiré y resolución de la imagen del documento |
mrz_valid | 0–100 | Validez de los checksums de la zona de lectura mecánica (MRZ) |
name_match | 0–100 | Coincidencia 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.
level | Significado |
|---|---|
ok | Un chequeo pasó. Informativo, positivo. |
warn | Un problema blando. Contribuye a bajar la confianza. |
err | Un 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
flagsno 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 esflags: [].
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:
text | Nivel | Significado |
|---|---|---|
auto_approved_all_checks_passed | ok | Todo pasó; presente en toda auto-aprobación |
mrz_checksums_valid | ok | Checksums de MRZ verificados |
mrz_viz_consistent | ok | La MRZ concuerda con los campos impresos del documento |
active_liveness_live | ok | Se superó el reto de prueba de vida activa |
image_blurry | warn | Imagen del documento demasiado borrosa para leerla con fiabilidad |
heavy_glare | warn | Reflejos que tapan el documento |
possible_screen_capture | warn | Patrón de moiré — el "documento" puede ser una foto de una pantalla |
low_resolution | warn | Imagen del documento por debajo de la resolución utilizable |
mrz_checksum_failed | warn | Hay MRZ pero los checksums no validan |
aml_possible_match | warn | Coincidencia blanda contra una lista de sanciones |
missing_images | err | Faltaban imágenes requeridas al momento de procesar |
no_face_detected_on_document | err | No se encontró cara en la foto del documento |
no_face_detected_on_selfie | err | No se encontró cara en la selfie |
face_match_below_critical_threshold | err | La selfie y la foto del documento están muy lejos entre sí |
active_liveness_spoof | err | El reto de prueba de vida activa indica un replay o una superficie plana |
document_quality_unusable | err | Imagen del documento inutilizable para verificar |
mrz_viz_mismatch | err | Una MRZ con checksum válido contradice los campos impresos — señal de falsificación |
aml_sanctions_match | err | Coincidencia 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.
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.
| Clave | Ejemplo |
|---|---|
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
- Ejemplos — implementaciones completas de handlers
- Verificación de firma — referencia del algoritmo
- Reintentos — garantías de entrega
- Webhooks — volver al índice de la sección