Widget examples
Complete integrations, not fragments. Each one covers the same three responsibilities, because all three are required and skipping any of them is how integrations break in production:
- Mount the widget with a publishable key.
- On
veridia:complete, send theverificationIdto your backend and record which of your users it belongs to — the widget will not hand that association back to you. - Get the verdict server-side, from the webhook or from
GET /v1/verify/{id}with a secret key.
None of these examples read a verdict in the browser. That is not an omission — the publishable key returns 401 secret_key_required on the status endpoint.
1. Plain HTML, end to end
Everything a working page needs, including the states that people forget: cancellation and denied camera.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Identity verification</title>
<!-- Order matters: face-api defines a global the widget needs. -->
<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 is "queued" | "processing" | "completed".
// It is NOT the verdict. Do not unlock anything here.
console.log('submitted', verificationId, pipelineStatus);
// Record the association on OUR side — it is not echoed back to us later.
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 + your backend
The widget mounts in the browser; the verdict arrives on your server through the webhook. This is the shape most production integrations end up with.
The component
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;
// The ONLY place this association exists. Persist it now.
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;
// Abandonment is a metric, not an error. Do not show a failure screen.
if (code === 'user_cancelled') {
window.analytics?.track('kyc_abandoned');
return;
}
// Our misconfiguration, not the user's problem — surface it to 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>;
}
Load the scripts once, in 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>
Recording the submission (your 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' });
}
// Take the user from the session, never from the request body — the browser
// controls the body, and this row is what later grants account access.
await db.kycVerifications.upsert({
verificationId,
userId: req.session.userId,
state: 'pending',
submittedAt: new Date(),
});
res.status(202).end();
});
Applying the verdict (your webhook handler)
The widget's job ended two steps ago. This is where an account gets unlocked.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// Raw body: the signature covers the bytes as received. Re-serializing the
// JSON changes the digest and every signature check fails.
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 first, work after: the dispatcher gives you 10 seconds.
res.status(200).end();
// Delivery is AT LEAST ONCE. `id` is the idempotency key — stable across
// retries and distinct per event, so a later manual approval of the same
// verification is not swallowed as a duplicate.
if (await alreadyProcessed(event.id)) return;
await markProcessed(event.id);
// The discriminator is `type`, not `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 lengths first: timingSafeEqual THROWS on a length mismatch, which
// turns a two-character forged signature into a 500 instead of a 401.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
The dispatcher re-signs on every retry, so the t in the header is always fresh. A 300-second tolerance is sufficient even for the last retry — do not widen it. See Signature verification.
3. Two subjects on one page
A loan application with an applicant and a guarantor. Each <veridia-widget> keeps its own configuration, camera and state, so instances do not interfere. face-api.js is loaded once globally by the script tag — there is no per-instance penalty.
<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;
// Both submitted — the application can move on. Note this says nothing
// about either verdict; those arrive by 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. Passport flow (skipping the back)
By default the widget asks for the back of the document for everything except passports. If you already know the document is a passport, document-type="passport" removes the back step on its own — you do not need require-doc-back.
<veridia-widget
publishable-key="qv_pubt_YOUR_KEY"
document-type="passport"
country="BR"
locale="pt">
</veridia-widget>
If you want to force the back step off for a non-passport document, the attribute value must be exactly "false" or "0". Anything else — including "no", "off", or an empty attribute — turns it on:
<!-- Two capture steps: front and selfie. -->
<veridia-widget document-type="dni" require-doc-back="false"></veridia-widget>
<!-- Three steps. "no" is not "false". -->
<veridia-widget document-type="dni" require-doc-back="no"></veridia-widget>
5. Placeholder while the scripts load
face-api.js is about 1.3 MB and the widget bundle about 45 KB. On a slow connection the element exists in the DOM before it is upgraded, so it renders as nothing. Hide it until registration completes:
<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>
What's next
- Events — the full error-code table behind these handlers.
- Configuration — every attribute and its real default.
- Webhooks — the verdict path in full.