Ejemplos del widget
Integraciones completas, no fragmentos. Cada una cubre las mismas tres responsabilidades, porque las tres son obligatorias y saltearse cualquiera es la forma en que las integraciones se rompen en producción:
- Montar el widget con una clave publicable.
- En
veridia:complete, enviar elverificationIda tu backend y registrar a cuál de tus usuarios pertenece: el widget no te va a devolver esa asociación. - Obtener el veredicto del lado del servidor, desde el webhook o desde
GET /v1/verify/{id}con una clave secreta.
Ninguno de estos ejemplos lee un veredicto en el navegador. Eso no es una omisión: la clave publicable devuelve 401 secret_key_required en el endpoint de estado.
1. HTML plano, de punta a punta
Todo lo que necesita una página funcionando, incluyendo los estados que la gente olvida: cancelación y cámara denegada.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verificación de identidad</title>
<!-- El orden importa: face-api define un global que el widget necesita. -->
<script src="https://widget.xxuxe.online/face-api.js"></script>
<script type="module" src="https://widget.xxuxe.online/veridia-widget.min.js"></script>
<style>
veridia-widget { display: block; width: 100%; max-width: 440px; margin: 0 auto; }
#status { max-width: 440px; margin: 16px auto; font: 15px/1.5 system-ui; }
.hidden { display: none; }
</style>
</head>
<body>
<main>
<h1>Verificá tu identidad</h1>
<veridia-widget
id="kyc"
publishable-key="qv_pubt_YOUR_KEY"
user-ref="customer-12345"
country="PY"
document-type="dni"
locale="es"
accent-color="#7C3AED">
</veridia-widget>
<p id="status" class="hidden"></p>
</main>
<script>
const widget = document.getElementById('kyc');
const status = document.getElementById('status');
const say = (text) => {
status.textContent = text;
status.classList.remove('hidden');
};
widget.addEventListener('veridia:complete', async (e) => {
const { verificationId, status: pipelineStatus } = e.detail;
// pipelineStatus es "queued" | "processing" | "completed".
// NO es el veredicto. No desbloquees nada acá.
console.log('enviado', verificationId, pipelineStatus);
// Registrá la asociación de NUESTRO lado: no nos la devuelven después.
await fetch('/api/kyc/submitted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verificationId }),
});
widget.hidden = true;
say('Gracias. Estamos revisando tus documentos: te va a llegar un email en breve.');
});
widget.addEventListener('veridia:error', (e) => {
const { code, message } = e.detail;
console.error('[veridia]', code, message);
switch (code) {
case 'user_cancelled':
say('Podés reiniciar la verificación cuando quieras.');
break;
case 'camera_denied':
say('Necesitamos acceso a la cámara. Activalo en la configuración de tu navegador y recargá esta página.');
break;
case 'camera_unavailable':
say('No se encontró una cámara en este dispositivo. Probá abrir esta página en tu teléfono.');
break;
case 'upload_failed':
case 'api_unreachable':
say('Problema de conexión. Revisá tu red e intentá de nuevo.');
break;
default:
say('Algo salió mal. Intentá de nuevo en unos minutos.');
}
});
</script>
</body>
</html>
2. React + tu backend
El widget se monta en el navegador; el veredicto llega a tu servidor por el webhook. Esta es la forma que termina teniendo la mayoría de las integraciones en producción.
El componente
import { useEffect, useRef, useState } from 'react';
export function KycStep({ userId, onSubmitted }) {
const widgetRef = useRef(null);
const [error, setError] = useState(null);
useEffect(() => {
const node = widgetRef.current;
if (!node) return;
const onComplete = async (e) => {
const { verificationId } = e.detail;
// El ÚNICO lugar donde existe esta asociación. Persistila ahora.
await fetch('/api/kyc/submitted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verificationId, userId }),
});
onSubmitted(verificationId);
};
const onError = (e) => {
const { code, message } = e.detail;
// El abandono es una métrica, no un error. No muestres pantalla de falla.
if (code === 'user_cancelled') {
window.analytics?.track('kyc_abandoned');
return;
}
// Nuestra mala configuración, no un problema del usuario: mostrásela a ops.
if (code === 'invalid_api_key' || code === 'insufficient_credits') {
console.error('[veridia] falla de configuración', code, message);
}
setError(code);
};
node.addEventListener('veridia:complete', onComplete);
node.addEventListener('veridia:error', onError);
return () => {
node.removeEventListener('veridia:complete', onComplete);
node.removeEventListener('veridia:error', onError);
};
}, [userId, onSubmitted]);
return (
<>
<veridia-widget
ref={widgetRef}
publishable-key={import.meta.env.VITE_VERIDIA_PUBLISHABLE_KEY}
user-ref={userId}
country="PY"
document-type="dni"
locale="es"
/>
{error && <ErrorNotice code={error} />}
</>
);
}
function ErrorNotice({ code }) {
const messages = {
camera_denied: 'Activá el acceso a la cámara en la configuración de tu navegador y recargá.',
camera_unavailable: 'No se encontró cámara. Probá esta página en tu teléfono.',
upload_failed: 'Problema de conexión. Intentá de nuevo.',
api_unreachable: 'Problema de conexión. Intentá de nuevo.',
rate_limited: 'Demasiados intentos. Esperá un minuto.',
};
return <p role="alert">{messages[code] ?? 'Algo salió mal. Intentá de nuevo.'}</p>;
}
Cargá los scripts una sola vez, en index.html:
<script src="https://widget.xxuxe.online/face-api.js"></script>
<script type="module" src="https://widget.xxuxe.online/veridia-widget.min.js"></script>
Registrar el envío (tu backend)
// POST /api/kyc/submitted
app.post('/api/kyc/submitted', requireSession, async (req, res) => {
const { verificationId } = req.body;
if (!/^vf_[A-Za-z0-9]{16,24}$/.test(verificationId ?? '')) {
return res.status(400).json({ error: 'bad_verification_id' });
}
// Tomá el usuario de la sesión, nunca del cuerpo del request: el navegador
// controla el cuerpo, y esta fila es la que después concede acceso a la cuenta.
await db.kycVerifications.upsert({
verificationId,
userId: req.session.userId,
state: 'pending',
submittedAt: new Date(),
});
res.status(202).end();
});
Aplicar el veredicto (tu handler de webhook)
El trabajo del widget terminó dos pasos atrás. Acá es donde se desbloquea una cuenta.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// Cuerpo crudo: la firma cubre los bytes tal como se recibieron. Re-serializar
// el JSON cambia el digest y toda validación de firma falla.
app.post('/webhooks/veridia',
express.raw({ type: 'application/json' }),
async (req, res) => {
const raw = req.body; // Buffer
const header = req.get('Veridia-Signature'); // "t=<unix>,v1=<hex>"
if (!verify(raw, header, process.env.VERIDIA_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw.toString('utf8'));
// Primero el 200, después el trabajo: el despachador te da 10 segundos.
res.status(200).end();
// La entrega es AL MENOS UNA VEZ. `id` es la clave de idempotencia: estable
// entre reintentos y distinta por evento, así que una aprobación manual
// posterior de la misma verificación no se descarta como duplicado.
if (await alreadyProcessed(event.id)) return;
await markProcessed(event.id);
// El discriminador es `type`, no `event`.
switch (event.type) {
case 'verification.approved':
await activateAccount(event.verificationId);
break;
case 'verification.rejected':
await rejectApplication(event.verificationId);
break;
case 'verification.review_required':
await queueForManualReview(event.verificationId);
break;
default:
console.warn('[veridia] tipo de evento desconocido', event.type);
}
});
function verify(rawBody, header, secret) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=').map((s) => s.trim()))
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${t}.`), rawBody]))
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
// Compará las longitudes primero: timingSafeEqual LANZA una excepción ante
// longitudes distintas, y eso convierte una firma falsificada de dos
// caracteres en un 500 en lugar de un 401.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
El despachador vuelve a firmar en cada reintento, así que el t de la cabecera siempre está fresco. Una tolerancia de 300 segundos alcanza incluso para el último reintento: no la amplíes. Ver Verificación de firma.
3. Dos sujetos en una misma página
Una solicitud de préstamo con un solicitante y un garante. Cada <veridia-widget> mantiene su propia configuración, cámara y estado, así que las instancias no interfieren entre sí. face-api.js se carga una sola vez de forma global por la etiqueta de script: no hay penalización por instancia.
<section>
<h2>Solicitante</h2>
<veridia-widget id="applicant"
publishable-key="qv_pubt_YOUR_KEY"
user-ref="loan-8842:applicant"
country="PY" document-type="dni" locale="es">
</veridia-widget>
</section>
<section>
<h2>Garante</h2>
<veridia-widget id="guarantor"
publishable-key="qv_pubt_YOUR_KEY"
user-ref="loan-8842:guarantor"
country="PY" document-type="dni" locale="es">
</veridia-widget>
</section>
<script>
const submitted = new Map();
for (const role of ['applicant', 'guarantor']) {
const el = document.getElementById(role);
el.addEventListener('veridia:complete', async (e) => {
submitted.set(role, e.detail.verificationId);
await fetch('/api/loan/kyc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loanId: '8842', role, verificationId: e.detail.verificationId }),
});
el.hidden = true;
// Ambos enviados: la solicitud puede avanzar. Notá que esto no dice nada
// sobre ninguno de los dos veredictos; esos llegan por webhook.
if (submitted.size === 2) {
document.getElementById('continue').disabled = false;
}
});
el.addEventListener('veridia:error', (e) => {
console.error(`[veridia:${role}]`, e.detail.code, e.detail.message);
});
}
</script>
4. Flujo de pasaporte (saltear el dorso)
Por defecto el widget pide el dorso del documento para todo excepto pasaportes. Si ya sabés que el documento es un pasaporte, document-type="passport" elimina el paso del dorso por sí solo: no necesitás require-doc-back.
<veridia-widget
publishable-key="qv_pubt_YOUR_KEY"
document-type="passport"
country="BR"
locale="pt">
</veridia-widget>
Si querés forzar la desactivación del paso del dorso para un documento que no es pasaporte, el valor del atributo tiene que ser exactamente "false" o "0". Cualquier otra cosa — incluidos "no", "off", o un atributo vacío — lo deja activado:
<!-- Dos pasos de captura: frente y selfie. -->
<veridia-widget document-type="dni" require-doc-back="false"></veridia-widget>
<!-- Tres pasos. "no" no es "false". -->
<veridia-widget document-type="dni" require-doc-back="no"></veridia-widget>
5. Placeholder mientras cargan los scripts
face-api.js pesa alrededor de 1,3 MB y el bundle del widget unos 45 KB. En una conexión lenta el elemento existe en el DOM antes de ser actualizado, así que no renderiza nada. Ocultalo hasta que el registro se complete:
<div id="kyc-loading">Cargando verificación…</div>
<veridia-widget id="kyc" hidden publishable-key="qv_pubt_YOUR_KEY"></veridia-widget>
<script>
customElements.whenDefined('veridia-widget').then(() => {
document.getElementById('kyc-loading').remove();
document.getElementById('kyc').hidden = false;
});
</script>
Qué sigue
- Eventos — la tabla completa de códigos de error detrás de estos handlers.
- Configuración — cada atributo y su valor por defecto real.
- Webhooks — el camino del veredicto en detalle.