Pular para o conteúdo principal

Exemplos do widget

Integrações completas, não fragmentos. Cada uma cobre as mesmas três responsabilidades, porque as três são obrigatórias e pular qualquer uma delas é como integrações quebram em produção:

  1. Monte o widget com uma chave publicável.
  2. No veridia:complete, envie o verificationId para o seu backend e registre a qual dos seus usuários ele pertence — o widget não vai devolver essa associação para você.
  3. Obtenha o veredito no servidor, pelo webhook ou por GET /v1/verify/{id} com uma chave secreta.

Nenhum destes exemplos lê um veredito no navegador. Isso não é uma omissão — a chave publicável retorna 401 secret_key_required no endpoint de status.

1. HTML puro, de ponta a ponta

Tudo o que uma página funcional precisa, incluindo os estados que as pessoas esquecem: cancelamento e câmera negada.

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Identity verification</title>

<!-- A ordem importa: face-api define um global de que o widget precisa. -->
<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>Verify your identity</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 é "queued" | "processing" | "completed".
// NÃO é o veredito. Não libere nada aqui.
console.log('submitted', verificationId, pipelineStatus);

// Registre a associação do NOSSO lado — ela não nos é devolvida depois.
await fetch('/api/kyc/submitted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verificationId }),
});

widget.hidden = true;
say('Thanks. We are reviewing your documents — you will get an email shortly.');
});

widget.addEventListener('veridia:error', (e) => {
const { code, message } = e.detail;
console.error('[veridia]', code, message);

switch (code) {
case 'user_cancelled':
say('You can restart the verification whenever you are ready.');
break;
case 'camera_denied':
say('We need camera access. Enable it in your browser settings and reload this page.');
break;
case 'camera_unavailable':
say('No camera found on this device. Try opening this page on your phone.');
break;
case 'upload_failed':
case 'api_unreachable':
say('Connection problem. Check your network and try again.');
break;
default:
say('Something went wrong. Please try again in a few minutes.');
}
});
</script>
</body>
</html>

2. React + seu backend

O widget monta no navegador; o veredito chega no seu servidor pelo webhook. Este é o formato com que a maioria das integrações de produção acaba.

O 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;

// O ÚNICO lugar onde esta associação existe. Persista agora.
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;

// Abandono é métrica, não erro. Não mostre tela de falha.
if (code === 'user_cancelled') {
window.analytics?.track('kyc_abandoned');
return;
}

// Configuração errada NOSSA, não problema do usuário — leve para ops.
if (code === 'invalid_api_key' || code === 'insufficient_credits') {
console.error('[veridia] configuration failure', 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: 'Enable camera access in your browser settings, then reload.',
camera_unavailable: 'No camera found. Try this page on your phone.',
upload_failed: 'Connection problem. Please try again.',
api_unreachable: 'Connection problem. Please try again.',
rate_limited: 'Too many attempts. Please wait a minute.',
};
return <p role="alert">{messages[code] ?? 'Something went wrong. Please try again.'}</p>;
}

Carregue os scripts uma única vez, no 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>

Registrando o envio (seu 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' });
}

// Pegue o usuário da sessão, nunca do corpo da requisição — o navegador
// controla o corpo, e esta linha é o que depois concede acesso à conta.
await db.kycVerifications.upsert({
verificationId,
userId: req.session.userId,
state: 'pending',
submittedAt: new Date(),
});

res.status(202).end();
});

Aplicando o veredito (seu handler de webhook)

O trabalho do widget terminou duas etapas atrás. É aqui que uma conta é liberada.

import express from 'express';
import crypto from 'node:crypto';

const app = express();

// Corpo cru: a assinatura cobre os bytes como recebidos. Reserializar o
// JSON muda o digest e toda checagem de assinatura falha.
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'));

// 200 primeiro, trabalho depois: o dispatcher te dá 10 segundos.
res.status(200).end();

// A entrega é AO MENOS UMA VEZ. `id` é a chave de idempotência — estável
// entre retentativas e distinta por evento, então uma aprovação manual
// posterior da mesma verificação não é engolida como duplicata.
if (await alreadyProcessed(event.id)) return;
await markProcessed(event.id);

// O discriminador é `type`, não `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] unknown event type', 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');
// Compare os tamanhos primeiro: timingSafeEqual LANÇA em divergência de
// tamanho, o que transforma uma assinatura forjada de dois caracteres em
// um 500 em vez de um 401.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

O dispatcher reassina a cada retentativa, então o t do cabeçalho está sempre fresco. Uma tolerância de 300 segundos é suficiente até para a última retentativa — não a amplie. Veja Verificação de assinatura.

3. Dois sujeitos em uma página

Uma solicitação de empréstimo com um solicitante e um fiador. Cada <veridia-widget> mantém sua própria configuração, câmera e estado, então as instâncias não interferem entre si. O face-api.js é carregado uma única vez globalmente pela tag de script — não há penalidade por instância.

<section>
<h2>Applicant</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>Guarantor</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 — a solicitação pode seguir. Note que isso não diz nada
// sobre nenhum dos vereditos; eles chegam 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. Fluxo de passaporte (pulando o verso)

Por padrão o widget pede o verso do documento para tudo, exceto passaportes. Se você já sabe que o documento é um passaporte, document-type="passport" remove a etapa do verso por si só — você não precisa de require-doc-back.

<veridia-widget
publishable-key="qv_pubt_YOUR_KEY"
document-type="passport"
country="BR"
locale="pt">
</veridia-widget>

Se você quer forçar o desligamento da etapa do verso para um documento que não é passaporte, o valor do atributo precisa ser exatamente "false" ou "0". Qualquer outra coisa — incluindo "no", "off", ou um atributo vazio — liga a etapa:

<!-- Duas etapas de captura: frente e selfie. -->
<veridia-widget document-type="dni" require-doc-back="false"></veridia-widget>

<!-- Três etapas. "no" não é "false". -->
<veridia-widget document-type="dni" require-doc-back="no"></veridia-widget>

5. Placeholder enquanto os scripts carregam

O face-api.js tem cerca de 1,3 MB e o bundle do widget cerca de 45 KB. Em uma conexão lenta o element existe no DOM antes de ser promovido, então ele renderiza como nada. Esconda-o até o registro completar:

<div id="kyc-loading">Loading verification…</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>

Próximos passos

  • Eventos — a tabela completa de códigos de erro por trás destes handlers.
  • Configuração — cada atributo e seu default real.
  • Webhooks — o caminho do veredito por completo.