POST /v1/verify/init
Inicia una verificación nueva. Devuelve un verificationId más tres slots de subida (frente del documento, dorso del documento, selfie) que el cliente usa para subir las imágenes.
POST https://api.xxuxe.online/v1/verify/init
Por qué se pre-crea el ID de verificación
Llamar primero a /init, en lugar de simplemente subir y enviar, hace dos cosas:
- Le permite al cliente hilvanar sus propios logs antes de que nada llegue al backend.
- Hace que la llamada posterior a
/submitsea idempotente — el mismoverificationIden el body del submit siempre significa la misma fila de base de datos.
Autenticación
Bearer token. Sirve cualquiera de las dos familias: publicable (qv_pub_ / qv_pubt_) o secreta (qv_sec_ / qv_sect_).
Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9
El tenant se deriva de la clave. No hay campo tenantId.
Body de la request
Todos los campos son opcionales. Podés hacer POST de un body vacío {} y obtener una verificación funcional. country y documentType mejoran de forma significativa la precisión del OCR, así que mandalos cuando los tengas.
| Campo | Tipo | Descripción |
|---|---|---|
userRef | string | Tu propio identificador de usuario, 1-128 caracteres. Se devuelve solo en webhooks |
country | string | ISO 3166-1 alpha-2, en mayúsculas (PY, BR, MX). Exactamente 2 caracteres |
documentType | string | Uno de dni, passport, drivers_license, national_id, other |
submittedFullName | string | Nombre completo tal como lo escribió el usuario, 1-255 caracteres. Alimenta el score name_match |
activeLiveness | boolean | Opta por el reto de prueba de vida activa. Por defecto false |
El esquema es no estricto: las claves que no reconoce se descartan antes de la validación. Mandar tenantId, callbackUrl o metadata acá devuelve 200 OK y el valor simplemente se pierde.
Ninguno de esos tres existe en /init. El tenant viene de tu clave de API; la entrega de webhooks se configura una vez por tenant en el panel, no por request.
userRef — dónde vuelve
userRef se devuelve en los payloads de webhook. No lo devuelve GET /v1/verify/:id, y no está en el evento veridia:complete del widget.
Así que si pensás reconciliar resultados por polling en lugar de por webhook, guardá de tu lado el mapeo verificationId → tu-usuario cuando llamás a /init. Ese es el único vínculo que vas a tener.
activeLiveness
Poner activeLiveness: true agrega un reto emitido por el servidor: la respuesta gana un bloque liveness, y el cliente tiene que capturar y subir una secuencia de frames guiada por balizas que va obteniendo de a una. Es la señal anti-inyección más fuerte disponible, y viene desactivada por defecto.
También cuesta unas 6x el volumen de requests — aproximadamente 26 requests por verificación en lugar de 4. Leé Límites de tasa antes de activarla para tráfico móvil a escala.
El protocolo del reto está implementado por el widget y los SDKs. Si estás construyendo un cliente propio y lo necesitás, hablá con nosotros antes de empezar.
Ejemplo de request
curl
curl -X POST https://api.xxuxe.online/v1/verify/init \
-H "Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9" \
-H "Content-Type: application/json" \
-d '{
"userRef": "customer-12345",
"country": "PY",
"documentType": "dni",
"submittedFullName": "Juan Carlos Perez"
}'
JavaScript / Node.js
const response = await fetch('https://api.xxuxe.online/v1/verify/init', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VERIDIA_PUBLISHABLE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
userRef: 'customer-12345',
country: 'PY',
documentType: 'dni',
submittedFullName: 'Juan Carlos Perez',
}),
});
const init = await response.json();
console.log(init.verificationId);
Python
import os
import requests
response = requests.post(
"https://api.xxuxe.online/v1/verify/init",
headers={
"Authorization": f"Bearer {os.environ['VERIDIA_PUBLISHABLE_KEY']}",
"Content-Type": "application/json",
},
json={
"userRef": "customer-12345",
"country": "PY",
"documentType": "dni",
"submittedFullName": "Juan Carlos Perez",
},
)
response.raise_for_status()
init = response.json()
print(init["verificationId"])
Respuesta
200 OK
{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"uploads": {
"docFront": {
"url": "https://api.xxuxe.online/v1/verify/upload/vf_AG07CDWRRFQV4T05ZXG2/doc-front",
"key": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/doc-front.jpg",
"method": "PUT",
"headers": {
"X-Veridia-Upload-Token": "5f3c1a9e0b7d4426a1c8ef53d20b9a7c6e14f8b2d9a03c57",
"Content-Type": "image/jpeg"
}
},
"docBack": {
"url": "https://api.xxuxe.online/v1/verify/upload/vf_AG07CDWRRFQV4T05ZXG2/doc-back",
"key": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/doc-back.jpg",
"method": "PUT",
"headers": {
"X-Veridia-Upload-Token": "5f3c1a9e0b7d4426a1c8ef53d20b9a7c6e14f8b2d9a03c57",
"Content-Type": "image/jpeg"
}
},
"selfie": {
"url": "https://api.xxuxe.online/v1/verify/upload/vf_AG07CDWRRFQV4T05ZXG2/selfie",
"key": "verif/tn_xyz/vf_AG07CDWRRFQV4T05ZXG2/selfie.jpg",
"method": "PUT",
"headers": {
"X-Veridia-Upload-Token": "5f3c1a9e0b7d4426a1c8ef53d20b9a7c6e14f8b2d9a03c57",
"Content-Type": "image/jpeg"
}
}
},
"expiresAt": 1714604000
}
Los tres slots se devuelven siempre. Usá docBack solo si tu tipo de documento tiene dorso.
Campos de la respuesta
| Campo | Tipo | Descripción |
|---|---|---|
verificationId | string | ^vf_[A-Za-z0-9]{16,24}$. Pasalo a /submit y a /verify/:id |
uploads.docFront | object | Slot de subida para el frente del documento |
uploads.docBack | object | Slot de subida para el dorso del documento |
uploads.selfie | object | Slot de subida para la selfie |
uploads.*.url | string | Dónde hacer PUT de los bytes crudos de la imagen |
uploads.*.key | string | Handle opaco — devolvelo en /submit |
uploads.*.method | string | Siempre "PUT" |
uploads.*.headers | object | Mandá esto textual. Contiene el token de subida |
expiresAt | number | Timestamp Unix en segundos (entero), 15 minutos después del init |
liveness | object | Presente solo cuando se mandó activeLiveness: true |
expiresAt es una cuenta entera de segundos, no un string ISO 8601. new Date(expiresAt * 1000) en JavaScript; datetime.fromtimestamp(expires_at) en Python.
Subir las imágenes
Esta es la parte que la mayoría de los clientes propios hace mal, así que tiene su propia sección.
PUT https://api.xxuxe.online/v1/verify/upload/{verificationId}/{role}
Las subidas no van a Cloudflare R2, y las URLs de los slots no son URLs presigned de S3. Apuntan al propio Worker de Veridia. El Worker valida el token, verifica que los bytes realmente sean un JPEG dentro de los límites de tamaño, y escribe al almacenamiento en tu nombre, estampando metadata controlada por el servidor que el cliente no puede falsificar.
Esa es una decisión de diseño deliberada, no un detalle de implementación: es el binding cámara→bytes. Ningún byte del cliente llega al almacenamiento sin pasar por esta compuerta, que es el prerrequisito de toda defensa anti-inyección construida encima. También significa que:
- Tu
connect-srcde CSP necesitahttps://api.xxuxe.online. Poner*.r2.cloudflarestorage.comen la lista permitida no hace nada. - Las reglas de firewall, el certificate pinning y las listas permitidas de egreso deben apuntar al host de la API.
Autenticación de las subidas
El endpoint de subida no acepta tu clave de API. Mandar Authorization: Bearer ... acá no tiene efecto — el middleware de auth no corre en esta ruta.
Se autentica con X-Veridia-Upload-Token, un token aleatorio de 192 bits que /init generó para esta única verificación y colocó dentro del objeto headers de cada slot. Está atado a la verificación y expira junto con la intención.
La regla práctica: reenviá slot.headers textual. No armes a mano el objeto de headers a partir del Content-Type que ves en el ejemplo — vas a perder el token y toda subida va a fallar con 400 invalid_body, reason: "missing_upload_token", y nunca vas a llegar a /submit.
Requisitos del body
| Regla | Valor | Falla |
|---|---|---|
| Formato | JPEG real — debe empezar con los bytes FF D8 FF | reason: "not_a_jpeg" |
| Tamaño mínimo | 100 bytes | reason: "image_too_small" |
| Tamaño máximo | 2 MB | reason: "image_too_large" |
PNG, HEIC, WebP y PDF son todos rechazados. Convertí a JPEG del lado del cliente antes de subir.
Ejemplo funcionando
async function uploadImage(slot, blob) {
const response = await fetch(slot.url, {
method: slot.method, // "PUT"
headers: slot.headers, // textual — lleva X-Veridia-Upload-Token
body: blob, // bytes JPEG crudos
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(`Upload failed: ${response.status} ${err.detail?.reason ?? ''}`);
}
return response.json(); // { ok: true, key: "verif/..." }
}
await uploadImage(init.uploads.docFront, docFrontBlob);
await uploadImage(init.uploads.selfie, selfieBlob);
// docBack solo si el documento tiene dorso
Fijate qué es lo que hace que esto funcione: headers: slot.headers. Todo el resto es accesorio.
Declarar el origen de la captura
Opcionalmente mandá X-Veridia-Capture-Source: camera o upload para registrar de dónde vinieron los píxeles.
Hay una regla que se aplica en vez de solo registrarse: upload es rechazado para el rol selfie y para cualquier frame de prueba de vida, con reason: "upload_source_forbidden_for_biometric". Una foto de galería de una cara no es una selfie, y ningún cliente honesto manda esa combinación. Los documentos sí pueden venir legítimamente de la galería.
El header es autodeclarado y por lo tanto falsificable — es telemetría y triage, no un control de seguridad. No construyas una defensa sobre él, y tampoco lo omitas: el tráfico honesto que se etiqueta a sí mismo mantiene limpio nuestro corpus forense.
Errores de subida
| HTTP | Código | detail.reason | Causa |
|---|---|---|---|
400 | invalid_body | missing_upload_token | No reenviaste slot.headers |
400 | invalid_body | invalid_upload_token | El token no corresponde a esta verificación |
400 | invalid_body | key_mismatch | El rol de la URL no es uno de los que emitió /init |
400 | invalid_body | not_a_jpeg | El body no es un JPEG |
400 | invalid_body | image_too_large | Más de 2 MB. Reescalá antes de subir — 2 MB de JPEG son 2-4 MP, de sobra para el OCR |
400 | invalid_body | image_too_small | Menos de 100 bytes (normalmente una subida truncada o vacía) |
400 | invalid_body | upload_source_forbidden_for_biometric | Origen upload en una selfie o un frame de prueba de vida |
404 | verification_not_found | — | La intención expiró (1 hora) o nunca existió |
429 | rate_limited | — | Límite por IP. Ver Límites de tasa |
Errores
| HTTP | Código de error | Cuándo |
|---|---|---|
400 | invalid_body | El body falló la validación — ver detail.fieldErrors |
401 | missing_api_key | Sin header Authorization |
401 | invalid_api_key | Clave revocada, malformada, o que nunca existió |
402 | insufficient_credits | El saldo del tenant es cero |
403 | origin_not_allowed | Request de navegador desde un origen que no está en una lista permitida no vacía |
429 | rate_limited | Límite por IP o por tenant |
500 | internal_error | Algo se rompió de nuestro lado — reportá el requestId |
Los códigos son missing_api_key, invalid_api_key e internal_error. No unauthorized, invalid_key ni internal. Catálogo completo: Errores.
Notas
- Los slots de subida son válidos por 15 minutos (
expiresAt). La intención de verificación en sí vive 1 hora — después de eso,/submity las subidas devuelvenverification_not_found. - La disposición de almacenamiento es
verif/<tenantId>/<verificationId>/<role>.jpg. La key se reconstruye del lado del servidor a partir de la intención validada, nunca a partir de la entrada del cliente. submittedFullNamenunca se coloca en una ruta de almacenamiento. Es PII y se queda en el registro de la intención.
Qué sigue
Después de /init, subí las imágenes y luego: