Skip to main content

Handling results

There are two ways to receive a verdict — polling and webhooks — and both run on your server.

The widget's veridia:complete event is not one of them. It fires in the browser the moment the API accepts the submission and carries only { verificationId, status }. Use it to show a spinner and to record which of your users this verificationId belongs to. The verdict comes from your backend.

MethodWhen to useLatencyNotes
PollingPrototypes, or when you have no public HTTPS endpointSeconds; you control the intervalSimple, but you must be running to see the result
WebhooksProductionSeconds after completionRetried; also delivers verdicts changed later by a human reviewer

For production, use webhooks. There is one thing polling structurally cannot give you: when a review case is later approved or rejected by a person in the dashboard, that decision arrives as a webhook. If you polled once, saw review, and stopped, you will never learn the outcome.

Read this before you write any branching logic

status and verdict are two different axes, and confusing them is the most expensive mistake this API allows.

FieldQuestion it answersValues
statusDid the pipeline finish running?queued, processing, completed, failed
verdictDid the person pass?approved, review, rejected

status: "completed" means the machinery ran to the end. It says nothing about whether the applicant is who they claim to be — a rejected verification is completed too.

// WRONG — this onboards every rejected applicant.
if (result.status === 'completed') {
enableUserAccount(userId);
}

// RIGHT — status tells you the result is ready; verdict tells you what it is.
if (result.status === 'completed') {
onVerdict(result);
}

Three related traps:

  • verdict may arrive as null. While the pipeline runs, the key is present with a null value — it is not absent. 'verdict' in data is true from the very first poll, so don't use key presence as a completion signal. Branch on status.
  • status: "failed" has no verdict at all. The pipeline errored. This is not a rejection; it is an absence of a result. Handle it separately — usually by asking the user to run the flow again.
  • review is a final verdict, not a transient state. Polling in a loop waiting for review to resolve into something else waits forever. It resolves when a human decides, which reaches you by webhook.

Method 1 — Polling GET /v1/verify/{id}

You need the secret key here

This endpoint requires a key from the secret family. A publishable key returns HTTP 401 with error: "secret_key_required".

Test-mode secret keys are prefixed qv_sect_; live ones are qv_sec_. If you built steps 1 and 2 with a qv_pubt_ key, your matching secret key is qv_sect_..., from the same API keys section of the dashboard. There is no qv_sec_ key in a test environment, and there is no qv_pub_test_ form of either prefix.

Never call this endpoint from a browser

Your publishable key is visible in your page source. If verdict reads accepted it, anyone could open developer tools and pull the KYC outcome for any verification. That's why the restriction exists — keep the secret key on your server.

curl

curl https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2 \
-H "Authorization: Bearer qv_sect_YOUR_TEST_SECRET_KEY"

While it is still running:

{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "processing",
"verdict": null,
"confidence": null,
"scores": null,
"flags": null,
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": null
}

Note that every key is already there, holding null. Nothing is missing while the pipeline runs — which is why key presence is useless as a completion test.

Once it's done:

{
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"status": "completed",
"verdict": "approved",
"confidence": 91.4,
"scores": {
"ocr_confidence": 78.0,
"face_match": 96.2,
"liveness": 91.5,
"doc_quality": 85.0,
"mrz_valid": 100.0,
"name_match": 88.3
},
"flags": [
{ "level": "ok", "text": "auto_approved_all_checks_passed" }
],
"submittedAt": "2026-05-01T18:39:05Z",
"completedAt": "2026-05-01T18:39:08Z"
}

The scores object

Six keys, all snake_case:

KeyMeaningRange
ocr_confidenceHow well text was read off the document0–100
face_matchSelfie against the document photo0–100
livenessLiveness signal0–100, or null
doc_qualityImage quality of the document0–100
mrz_validMachine-readable-zone checksum validity0–100
name_matchsubmitted-full-name against the name on the document0–100

Two failure modes to avoid:

The keys are not camelCase. scores.faceMatch is undefined. That fails quietly and dangerously: undefined < 80 evaluates to false, so a threshold check like if (scores.faceMatch < 80) reject() never fires, and a security control you believe you wrote is permanently disabled without any error.

liveness can be null — when there was no liveness signal, or the liveness step errored. It is the only nullable score, and it is the one people most often feed into arithmetic. Check it before you use it:

const liveness = result.scores.liveness;
if (liveness !== null && liveness < 70) { /* ... */ }

The flags array

A list of objects, not strings: { level, text }. There are exactly three levels:

LevelMeaning
okA check passed. Positive signal
warnSomething was noticed but is not disqualifying
errA hard failure

ok is the one that catches people out. Every approved verification carries { "level": "ok", "text": "auto_approved_all_checks_passed" }, so an approved result never has an empty flags array. If you treat "any flag" as "a problem", you will route 100% of your approvals into manual review because the success marker looks like a warning.

Filter by level, and note that the level for a genuine hard failure is err:

const hardFailures = result.flags.filter(f => f.level === 'err');

Flag texts you're likely to care about include possible_screen_capture (a photo of a screen rather than a document), mrz_viz_mismatch (the MRZ disagrees with the printed data), active_liveness_spoof, and aml_sanctions_match / aml_possible_match. Treat the text as an opaque string you match against, not something to show the end user — telling a fraudster which check caught them is free tuning feedback.

JavaScript / Node.js

async function pollVerification(verificationId, maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
const response = await fetch(
`https://api.xxuxe.online/v1/verify/${verificationId}`,
{ headers: { Authorization: `Bearer ${process.env.VERIDIA_SECRET_KEY}` } }
);

if (!response.ok) {
const err = await response.json();
// secret_key_required means you sent a publishable key.
throw new Error(`${err.error}: ${err.message} (requestId ${err.requestId})`);
}

const data = await response.json();

// status is about the pipeline, not the person.
if (data.status === 'completed') return data;
if (data.status === 'failed') {
throw new Error(`Verification ${verificationId} failed to process`);
}

await new Promise(r => setTimeout(r, 1000));
}
throw new Error('Verification did not complete in time');
}

const result = await pollVerification('vf_AG07CDWRRFQV4T05ZXG2');
onVerdict(result); // branch on result.verdict, never on result.status

Python

import os
import time
import requests

def poll_verification(verification_id, max_attempts=30):
headers = {"Authorization": f"Bearer {os.environ['VERIDIA_SECRET_KEY']}"}

for _ in range(max_attempts):
r = requests.get(
f"https://api.xxuxe.online/v1/verify/{verification_id}",
headers=headers,
timeout=10,
)
r.raise_for_status()
data = r.json()

if data["status"] == "completed":
return data
if data["status"] == "failed":
raise RuntimeError(f"Verification {verification_id} failed to process")

time.sleep(1)

raise TimeoutError("Verification did not complete in time")

result = poll_verification("vf_AG07CDWRRFQV4T05ZXG2")
on_verdict(result["verdict"], result["scores"], result["flags"])

PHP

<?php
function pollVerification(string $verificationId, int $maxAttempts = 30): array {
$secretKey = $_ENV['VERIDIA_SECRET_KEY'];

for ($i = 0; $i < $maxAttempts; $i++) {
$ch = curl_init("https://api.xxuxe.online/v1/verify/$verificationId");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $secretKey"]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Check the status before trusting the body. Without this, a 401 —
// easily reached by polling with a publishable key, which this endpoint
// refuses — leaves $data['status'] undefined, the loop spins its full
// 30 attempts and then reports a timeout. The diagnosis would point at
// the pipeline being slow when the real problem is the key.
if ($httpCode === 401) {
throw new RuntimeException(
"401 reading the verdict. This endpoint needs a SECRET key (qv_sec_*); "
. "a publishable key can start and submit but not read results."
);
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new RuntimeException("Veridia returned HTTP $httpCode: $response");
}

$data = json_decode($response, true);

if ($data['status'] === 'completed') {
return $data;
}
if ($data['status'] === 'failed') {
throw new RuntimeException("Verification $verificationId failed to process");
}

sleep(1);
}

throw new RuntimeException("Verification did not complete in time");
}

$result = pollVerification("vf_AG07CDWRRFQV4T05ZXG2");
onVerdict($result);

One request per second for thirty seconds is well inside the limit for this endpoint (600 requests per minute per tenant), so these loops won't rate-limit you.

Setup

There is one webhook per tenant, configured in your dashboard under Settings → Webhook. Two fields:

  1. URL — your endpoint. It must start with https://; the dashboard refuses anything else. Private, loopback, link-local and CGNAT addresses are rejected as well.
  2. Secretyou choose this value, minimum 24 characters. It is not generated for you and not revealed back to you afterwards; the field is write-only and leaving it blank keeps the current secret. Generate something random, store it in your own secret manager, and paste it in.

That's the whole configuration. There is no endpoint list and no event selection: you receive all three event types or none. The dashboard's Webhooks page is the delivery history, not a place to add endpoints.

Local development needs a tunnel

Because the URL must be HTTPS and public addresses only, you cannot point a webhook at http://localhost:3000. Use ngrok, localtunnel, or Cloudflare Tunnel and register the public HTTPS URL it gives you. This is the only way to test webhooks locally.

What you receive

The payload is flat — there is no data wrapper:

{
"id": "evt_9f2c1b7a4e5d38c0a1b2c3d4e5f60718",
"type": "verification.review_required",
"createdAt": 1777663148,
"tenantId": "tn_default_demo",
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"verdict": "review",
"confidence": 77.85,
"userRef": "customer-12345",
"scores": {
"ocr_confidence": 20.0,
"face_match": 99.5,
"liveness": 88.0,
"doc_quality": 75.7,
"mrz_valid": 0.0,
"name_match": 61.2
},
"flags": [
{ "level": "warn", "text": "heavy_glare" }
],
"fieldsExtracted": {
"full_name": "MARIA GONZALEZ",
"document_number": "1234567",
"date_of_birth": "1990-04-12",
"nationality": "PRY",
"document_type": "dni"
},
"latencyMs": 2140
}
FieldNotes
idevt_ + hex. The deduplication key. Stable across retries of the same event
typeThe discriminator you switch on. Not event
createdAtUnix time in seconds, an integer — not an ISO string
tenantId, verificationIdIdentifiers
verdict, confidenceThe outcome
userRefWhat you passed as user-ref. May be null if you never set it
scoresSame six snake_case keys as above; liveness may be null
flagsSame { level, text } objects, same ok / warn / err levels
fieldsExtractedIdentity data read off the document — see the warning below
latencyMsPipeline time. null when a human made the decision

The event does not include metadata, submittedAt, or completedAt. If you need submission timestamps, read them from GET /v1/verify/{id}.

fieldsExtracted is personal data

Every event carries the person's full name, document number, and date of birth. Your webhook endpoint is therefore a system that processes identity data, and so is anything downstream of it. In particular: do not log the raw request body to a general-purpose logging service, and do not forward it to third-party error trackers, without deciding that deliberately. Most teams discover this after the PII is already in their log index, where deleting it is far more work than never sending it.

The three event types

Exactly three, and no others:

  • verification.approved
  • verification.rejected
  • verification.review_required

There is no verification.created and no verification.expired. Handle unknown types gracefully anyway — but don't build logic for specific ones that don't exist.

switch (payload.type) { // `type`, not `event`
case 'verification.approved': return onApproved(payload);
case 'verification.rejected': return onRejected(payload);
case 'verification.review_required': return onReview(payload);
default:
console.warn('Unknown Veridia event type:', payload.type);
}

Verifying the signature

Each delivery carries two headers:

Veridia-Signature: t=1777663148,v1=5f8c...e21
Veridia-Event: verification.review_required

The MAC is HMAC-SHA256 over the bytes "<t>." + rawBody, keyed with your webhook secret. The timestamp lives inside the signature header — there is no separate timestamp header, and no X- prefixed variant of either.

Verify against the raw request bytes. Parsing the JSON and re-serializing it changes the digest and your verification will fail, no matter how correct the rest of your code is. In Express use express.raw(), in Flask request.get_data(), in PHP php://input.

A 300-second tolerance on t is correct and you should not widen it. The dispatcher re-signs on every retry, so the sixth attempt — twelve minutes after the first — arrives with a fresh t, not a stale one. Widening the window buys you nothing and weakens your replay protection.

Full worked examples in four languages: signature verification.

Delivery and retries

Delivery is at least once. Deliveries are queued in a transactional outbox and retried on failure with backoff 1s, 5s, 30s, 2m, 10m — six attempts across roughly 12.6 minutes. After that the delivery is parked as failed, and an operator can re-queue it from the dashboard.

The common cause of a duplicate is not a bug on either side: your handler processed the event correctly but took longer than the timeout to answer, so the 200 never arrived and the dispatcher tried again. Assume it will happen.

Deduplicate on id. It is the same value across every retry of an event, and it is different for every event — including two events about the same verification, which is exactly the case that a key like verificationId + type gets wrong. A verification that comes back review_required and is later approved by a reviewer produces two events for one verificationId; dedupe on anything but id and you will discard the human's decision and leave that user pending forever.

const seen = await db.webhookEvents.findUnique({ where: { id: payload.id } });
if (seen) return res.status(200).end(); // already handled
await db.webhookEvents.create({ data: { id: payload.id } });

Respond 2xx quickly and do the real work afterwards — the delivery times out after 10 seconds.

Acting on the verdict

async function onVerdict(payload) {
// Whichever path you came from, map back to your user first.
// From a webhook: payload.userRef (if you set user-ref).
// From polling: your own verificationId -> userId table, saved when
// the widget fired veridia:complete.
const userId = await resolveUser(payload);

switch (payload.verdict) {
case 'approved':
await enableUserAccount(userId);
break;

case 'review':
// Final until a human decides. The decision arrives as a second webhook.
await queueForReview(userId, payload.verificationId, payload.flags);
break;

case 'rejected':
await blockUserKyc(userId, payload.verificationId);
break;

default:
// verdict was null: the pipeline hasn't finished. Don't act.
console.error('No verdict yet for', payload.verificationId);
}
}

What's next

Quickstart complete. From here, depending on what you're building:

  • Widget docs — every attribute, event, and styling option
  • API Reference — full REST API for server-side and custom-client integrations
  • Webhooks — signature verification, retry behavior, worked examples
  • Compliance — data retention and regulatory posture

Need help? Contact support.