Skip to main content

Event types

Veridia emits three event types, all of them verification outcomes. Every event has the same shape; only type, verdict, and the values differ.

typeverdictFired when
verification.approvedapprovedConfidence at or above the approval threshold, no hard failures, no sanctions hit
verification.rejectedrejectedConfidence below the rejection threshold
verification.review_requiredreviewAnything in between, or any hard failure, or a strong sanctions match

There are no other types. There is no verification.created, verification.expired, or verification.refunded. You should still handle an unrecognized type without erroring — log it and return 2xx — but do not build logic against a name that does not appear in the table above.

How the verdict is decided

The thresholds are deployment-wide environment settings, not per-tenant configuration. There is no knob support can turn for your account.

ConfidenceVerdict
>= 90approved
6089.99review
< 60rejected

Two rules override the score, and both exist to fail toward a human rather than toward an approval:

  • Any hard failure never auto-approves. No face found on the document or the selfie, face match below the critical threshold, an active-liveness spoof, a failed MRZ checksum, an unusable document image, or an MRZ that contradicts the document's printed fields. Regardless of confidence, the case becomes review — or rejected if confidence is also below 60.
  • A strong sanctions match forces review. It downgrades an otherwise-approved case and never auto-rejects. The identity may be perfectly valid; a person has to clear it.

The practical consequence: a case scoring 87 is review, not approved. If you planned your funnel around "high confidence means automatic onboarding," size the manual queue accordingly.

Common fields

Every event carries exactly these fields.

FieldTypeAlways presentDescription
idstringYesevt_<32 hex>. The idempotency key — stable across retries of this event, unique across events. Also sent as the Veridia-Event-Id header.
typestringYesThe event type. This is the field you switch on.
createdAtnumberYesUnix timestamp in seconds (integer), when the event was queued. Not ISO 8601.
tenantIdstringYesYour tenant ID.
verificationIdstringYesThe ID from /v1/verify/init (vf_*).
envstringYes"live" or "test". Test and live keys deliver to the SAME URL, so branch on this before acting — a synthetic verification.approved from a QA run must never activate a real account. Events queued before 2026-07-30 predate the field; treat a missing value as "live".
verdictstringYesapproved, review, or rejected.
confidencenumberYesOverall weighted score, 0–100.
userRefstring | nullYesThe userRef you passed to /init, or null if you passed none.
scoresobjectYesPer-signal breakdown. See below.
flagsarrayYesObjects of { level, text }. See below.
fieldsExtractedobjectYesIdentity data read off the document. PII. See below.
latencyMsnumber | nullYesPipeline time in milliseconds. null for events produced by a human reviewer's decision, where pipeline latency would be meaningless.

Fields the payload does not contain, despite being plausible: event, metadata, submittedAt, completedAt, status. The metadata you may have sent to /v1/verify/submit is stored with the verification but is not echoed in the event — userRef, set at /init, is the only correlation field that comes back.

The only timestamp is createdAt, in seconds. If you persist a KYC completion date, derive it from that:

const kycCompletedAt = new Date(payload.createdAt * 1000);

scores

Six keys, all snake_case. The object has the same shape in all three event types.

KeyRangeMeaning
ocr_confidence0–100The extraction model's own confidence in the document text it read
face_match0–100Biometric similarity between the selfie and the document photo
liveness0–100 or nullPassive anti-spoofing on the selfie. null when no signal was available or the model errored
doc_quality0–100Sharpness, glare, moiré, and resolution of the document image
mrz_valid0–100Machine-readable-zone checksum validity
name_match0–100Fuzzy match between submittedFullName and the name read off the document

liveness is the only nullable member, and it is the one integrators most often threshold on. Guard it:

const liveness = payload.scores.liveness;
if (liveness !== null && liveness < 50) {
// treat as a weak signal
}

A missing liveness is not a passing liveness. If your risk policy depends on it, treat null as "unknown" and route accordingly rather than defaulting it to a number.

confidence is a weighted combination of these signals, capped by the hard-failure rules above. It is not the average.

flags

An array of objects — not strings:

"flags": [
{ "level": "warn", "text": "heavy_glare" },
{ "level": "err", "text": "mrz_viz_mismatch" }
]

Levels

There are three, and ok is the one that surprises people.

levelMeaning
okA check passed. Informational, positive.
warnA soft problem. Contributes to lower confidence.
errA hard problem. Prevents auto-approval outright.

There is no info and no critical. Two failure modes follow directly:

  • Filtering for level === 'critical' matches nothing, so every case lands in your queue at normal priority — including the ones with hard failures, which are exactly the ones a reviewer should see first. Filter for 'err'.
  • Treating a non-empty flags array as "something is wrong" alarms on success. Every auto-approved verification carries { "level": "ok", "text": "auto_approved_all_checks_passed" } as its first flag. An approved event is never flags: [].
const hasHardFailure = payload.flags.some(f => f.level === 'err');
const problems = payload.flags.filter(f => f.level !== 'ok');

Flag values

text values that the pipeline emits today:

textLevelMeaning
auto_approved_all_checks_passedokEverything passed; present on every auto-approval
mrz_checksums_validokMRZ checksums verified
mrz_viz_consistentokMRZ agrees with the document's printed fields
active_liveness_liveokThe active liveness challenge was passed
image_blurrywarnDocument image too soft to read reliably
heavy_glarewarnReflective glare obscuring the document
possible_screen_capturewarnMoiré pattern — the "document" may be a photo of a screen
low_resolutionwarnDocument image below the usable resolution
mrz_checksum_failedwarnMRZ present but checksums do not validate
aml_possible_matchwarnSoft match against a sanctions list
missing_imageserrRequired images were absent at processing time
no_face_detected_on_documenterrNo face found in the document photo
no_face_detected_on_selfieerrNo face found in the selfie
face_match_below_critical_thresholderrSelfie and document photo are far apart
active_liveness_spooferrThe active liveness challenge indicates a replay or a flat surface
document_quality_unusableerrDocument image unusable for verification
mrz_viz_mismatcherrChecksum-valid MRZ contradicts the printed fields — a forgery signal
aml_sanctions_matcherrStrong match against a sanctions list; forces review, never auto-rejects

Two more notes. Face-matching errors are surfaced as err flags whose text is the underlying error string, so treat this list as the set of known values rather than a closed enum — match on what you recognize and pass the rest through to your reviewers verbatim. And the two AML flags are the ones with regulatory weight: aml_sanctions_match means a person must clear the case before onboarding, no matter how good the biometrics were.

Do not show flags to the end user

Telling someone which signal caught them is a free tutorial for the next attempt. Show a generic "we could not verify your document, please contact support" and keep flags for your internal review queue.

fieldsExtracted

The identity data read off the document.

KeyExample
full_name"MARIA ELENA GONZALEZ"
document_number"4567890"
date_of_birth"1991-04-17"
nationality"PRY"
document_type"dni"

Any value may be null — the pipeline reports what it could read. These are OCR and MRZ outputs, not assertions Veridia makes about the person. nationality in particular is read off the document and is frequently absent or wrong in real traffic; do not use it to drive logic that matters without corroboration.

This object is personal data. Your endpoint must be HTTPS (the dashboard enforces it), and if you log raw bodies, your logs now hold identity documents.

Examples

verification.approved

{
"id": "evt_9f2c41ab7d8e4c05b6a3e17f2d904c8b",
"type": "verification.approved",
"createdAt": 1753142348,
"tenantId": "tn_default_demo",
"verificationId": "vf_AG07CDWRRFQV4T05ZXG2",
"verdict": "approved",
"confidence": 93.1,
"userRef": "customer-12345",
"scores": {
"ocr_confidence": 78.0,
"face_match": 96.2,
"liveness": 91.5,
"doc_quality": 85.0,
"mrz_valid": 100.0,
"name_match": 88.0
},
"flags": [
{ "level": "ok", "text": "auto_approved_all_checks_passed" },
{ "level": "ok", "text": "mrz_checksums_valid" }
],
"fieldsExtracted": {
"full_name": "MARIA ELENA GONZALEZ",
"document_number": "4567890",
"date_of_birth": "1991-04-17",
"nationality": "PRY",
"document_type": "dni"
},
"latencyMs": 3184
}
case 'verification.approved':
await db.users.update(payload.userRef, {
kycStatus: 'verified',
kycCompletedAt: new Date(payload.createdAt * 1000),
kycVerificationId: payload.verificationId,
});
await sendWelcomeEmail(payload.userRef);
break;

verification.rejected

{
"id": "evt_3b71dd90c4a24f6ea5c0812fb7e39d14",
"type": "verification.rejected",
"createdAt": 1753145532,
"tenantId": "tn_default_demo",
"verificationId": "vf_BX18DEXSGFRX5U16YH3Q",
"verdict": "rejected",
"confidence": 42.1,
"userRef": "customer-67890",
"scores": {
"ocr_confidence": 65.0,
"face_match": 31.4,
"liveness": 88.0,
"doc_quality": 50.5,
"mrz_valid": 0.0,
"name_match": 44.0
},
"flags": [
{ "level": "err", "text": "face_match_below_critical_threshold" },
{ "level": "warn", "text": "image_blurry" }
],
"fieldsExtracted": {
"full_name": "J. PEREZ",
"document_number": null,
"date_of_birth": null,
"nationality": null,
"document_type": "dni"
},
"latencyMs": 2971
}
case 'verification.rejected':
await db.users.update(payload.userRef, {
kycStatus: 'rejected',
kycRejectionFlags: payload.flags, // internal only
});
await sendGenericFailureEmail(payload.userRef);
break;

verification.review_required

{
"id": "evt_c05e8a1746bf4d92ae37b6c2019df8aa",
"type": "verification.review_required",
"createdAt": 1753149933,
"tenantId": "tn_default_demo",
"verificationId": "vf_CY29EFYTGFSZ6V27ZH4R",
"verdict": "review",
"confidence": 87.3,
"userRef": "customer-11111",
"scores": {
"ocr_confidence": 72.0,
"face_match": 79.5,
"liveness": null,
"doc_quality": 45.0,
"mrz_valid": 100.0,
"name_match": 91.0
},
"flags": [
{ "level": "warn", "text": "heavy_glare" },
{ "level": "err", "text": "document_quality_unusable" }
],
"fieldsExtracted": {
"full_name": "CARLOS ALBERTO RIVAS",
"document_number": "3312004",
"date_of_birth": "1988-11-02",
"nationality": null,
"document_type": "dni"
},
"latencyMs": 3402
}

Note the shape of this one: confidence is 87.3 — above what an integrator might assume is an approval — and liveness is null. The err flag is what put it in review.

case 'verification.review_required':
await db.users.update(payload.userRef, { kycStatus: 'pending_review' });
await reviewQueue.add({
verificationId: payload.verificationId,
userRef: payload.userRef,
flags: payload.flags,
priority: payload.flags.some(f => f.level === 'err') ? 'high' : 'normal',
});
break;

review is terminal until a person acts on it. No further event arrives on its own. When a reviewer decides the case in the Veridia dashboard, you receive a second event — verification.approved or verification.rejected — for the same verificationId, with a fresh id. Your handler must apply it. This is why the dedup key has to be id and not anything derived from verificationId.

Routing

async function handleVeridiaWebhook(payload) {
switch (payload.type) {
case 'verification.approved':
return handleApproved(payload);

case 'verification.rejected':
return handleRejected(payload);

case 'verification.review_required':
return handleReviewRequired(payload);

default:
// Unknown type: log and succeed. Never throw — an exception here
// becomes a 5xx, and the event gets retried for ~12.6 minutes.
logger.warn('Unknown Veridia event type', {
type: payload.type,
eventId: payload.id,
verificationId: payload.verificationId,
});
}
}

What's next