Saltar al contenido principal

Webhooks

Un webhook es la forma en que te enterás del resultado de una verificación. Cuando una verificación llega a un veredicto — automáticamente, o porque lo decidió un revisor humano — Veridia hace un POST de un cuerpo JSON firmado a la URL que configuraste.

Esta es la única manera push de obtener un veredicto. El evento veridia:complete del widget te dice que el usuario terminó de enviar; no trae el veredicto. GET /v1/verify/{id} sí lo trae, pero requiere una clave secreta y requiere que hagas polling.

Los tres eventos

Existen exactamente tres tipos de evento. No hay verification.created, no hay verification.expired, y no hay forma de suscribirse a un subconjunto — un tenant recibe los tres o ninguno.

typeverdictSignificado
verification.approvedapprovedAprobada. Podés hacer onboarding sin riesgo.
verification.rejectedrejectedFalló. No hagas onboarding.
verification.review_requiredreviewUn humano tiene que mirarlo. No es un estado transitorio — no llega ningún evento más hasta que un revisor decida.

Cuando después un revisor decide un caso en review, recibís un segundo evento — verification.approved o verification.rejected — para el mismo verificationId, con un id nuevo. Ese segundo evento es el que desbloquea al usuario. Los handlers que colapsan ambos eventos en una sola clave de deduplicación descartan la decisión humana en silencio; ver Idempotencia.

Un payload completo

El cuerpo es plano. No hay envoltorio data.

{
"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
}

Referencia campo por campo completa: Tipos de evento.

Tres cosas que rompen integraciones

Vale la pena leerlas antes de escribir el handler, porque cada una falla en silencio.

1. El discriminador es type, no event

switch (payload.type) { /* ... */ } // correcto
switch (payload.event) { /* ... */ } // siempre undefined → cae en default

No existe la clave event en el cuerpo. Un switch sobre ella no matchea nada, tu handler devuelve 200 OK, y nunca se aplica ningún veredicto. Los usuarios aprobados quedan pendientes para siempre, y los rechazados también. En tus logs de error no aparece nada, porque nada dio error.

2. Deduplicá por el id del evento

La entrega es al menos una vez. Un handler que procesó un evento con éxito pero tardó en responder lo va a recibir de nuevo. Necesitás una clave de deduplicación, y la correcta es id — el valor evt_*, que es estable en todos los reintentos del mismo evento y único entre eventos distintos.

const dedupKey = payload.id; // correcto
const dedupKey = `${payload.verificationId}:${payload.type}`; // MAL

La segunda forma parece razonable y es la trampa. Una verificación que va a review_required y después es aprobada por un revisor produce dos eventos con valores de type distintos, así que esa clave sobrevive — pero cualquier clave armada solo con verificationId, o con un campo que evalúa a undefined, colapsa la decisión humana dentro del evento anterior de la máquina y la descarta. Usá id. También viene en el header Veridia-Event-Id, así que podés deduplicar antes de parsear el cuerpo.

3. Las claves de scores son snake_case, y liveness puede ser null

payload.scores.face_match // 96.2
payload.scores.faceMatch // undefined

undefined < 70 es false en JavaScript, así que un umbral escrito contra la grafía camelCase nunca se dispara: falla abierto y deja pasar a todo el mundo. Y scores.liveness es null cuando no hubo señal de prueba de vida o el modelo dio error, así que hacer aritmética sobre él sin chequear null explota en producción.

fieldsExtracted son datos personales

fieldsExtracted lleva PII de identidad: nombre completo, número de documento y fecha de nacimiento, más nacionalidad y tipo de documento. Es el contenido transcripto de un documento de identidad emitido por un Estado.

Dos consecuencias:

  • Tu endpoint tiene que ser HTTPS. El panel se niega a guardar una URL que no empiece con https://, precisamente porque si no este cuerpo cruzaría la red en texto plano. No hay excepción HTTP, ni siquiera para localhost — ver pruebas locales.
  • Tus logs pasan a ser un almacén de datos personales. Si logueás los cuerpos crudos de los webhooks (algo normal y sensato para debuggear), tu política de retención de logs y tus controles de acceso ahora aplican a documentos de identidad. Redactá fieldsExtracted antes de loguear, o asumilo de forma deliberada.

Los valores pueden ser null — el pipeline extrae lo que puede leer. Son salidas de OCR/MRZ, no valores que Veridia afirme como verdaderos.

Adónde ir ahora

  • Panorama general — configurar el endpoint, los headers y el patrón confirmar-primero-procesar-después
  • Verificación de firma — el algoritmo HMAC, con código funcional para cuatro stacks
  • Tipos de evento — cada campo, cada score, cada flag
  • Reintentos — el esquema de backoff, las garantías de entrega y cómo recuperar un evento fallido
  • Ejemplos — handlers completos para Express, FastAPI, Laravel y Cloudflare Workers