Skip to main content

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:

  1. Mount the widget with a publishable key.
  2. On veridia:complete, send the verificationId to your backend and record which of your users it belongs to — the widget will not hand that association back to you.
  3. 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.