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.
type | verdict | Fired when |
|---|---|---|
verification.approved | approved | Confidence at or above the approval threshold, no hard failures, no sanctions hit |
verification.rejected | rejected | Confidence below the rejection threshold |
verification.review_required | review | Anything 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.
| Confidence | Verdict |
|---|---|
>= 90 | approved |
60 – 89.99 | review |
< 60 | rejected |
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— orrejectedif 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.
| Field | Type | Always present | Description |
|---|---|---|---|
id | string | Yes | evt_<32 hex>. The idempotency key — stable across retries of this event, unique across events. Also sent as the Veridia-Event-Id header. |
type | string | Yes | The event type. This is the field you switch on. |
createdAt | number | Yes | Unix timestamp in seconds (integer), when the event was queued. Not ISO 8601. |
tenantId | string | Yes | Your tenant ID. |
verificationId | string | Yes | The ID from /v1/verify/init (vf_*). |
env | string | Yes | "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". |
verdict | string | Yes | approved, review, or rejected. |
confidence | number | Yes | Overall weighted score, 0–100. |
userRef | string | null | Yes | The userRef you passed to /init, or null if you passed none. |
scores | object | Yes | Per-signal breakdown. See below. |
flags | array | Yes | Objects of { level, text }. See below. |
fieldsExtracted | object | Yes | Identity data read off the document. PII. See below. |
latencyMs | number | null | Yes | Pipeline 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.
| Key | Range | Meaning |
|---|---|---|
ocr_confidence | 0–100 | The extraction model's own confidence in the document text it read |
face_match | 0–100 | Biometric similarity between the selfie and the document photo |
liveness | 0–100 or null | Passive anti-spoofing on the selfie. null when no signal was available or the model errored |
doc_quality | 0–100 | Sharpness, glare, moiré, and resolution of the document image |
mrz_valid | 0–100 | Machine-readable-zone checksum validity |
name_match | 0–100 | Fuzzy 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.
level | Meaning |
|---|---|
ok | A check passed. Informational, positive. |
warn | A soft problem. Contributes to lower confidence. |
err | A 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
flagsarray 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 neverflags: [].
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:
text | Level | Meaning |
|---|---|---|
auto_approved_all_checks_passed | ok | Everything passed; present on every auto-approval |
mrz_checksums_valid | ok | MRZ checksums verified |
mrz_viz_consistent | ok | MRZ agrees with the document's printed fields |
active_liveness_live | ok | The active liveness challenge was passed |
image_blurry | warn | Document image too soft to read reliably |
heavy_glare | warn | Reflective glare obscuring the document |
possible_screen_capture | warn | Moiré pattern — the "document" may be a photo of a screen |
low_resolution | warn | Document image below the usable resolution |
mrz_checksum_failed | warn | MRZ present but checksums do not validate |
aml_possible_match | warn | Soft match against a sanctions list |
missing_images | err | Required images were absent at processing time |
no_face_detected_on_document | err | No face found in the document photo |
no_face_detected_on_selfie | err | No face found in the selfie |
face_match_below_critical_threshold | err | Selfie and document photo are far apart |
active_liveness_spoof | err | The active liveness challenge indicates a replay or a flat surface |
document_quality_unusable | err | Document image unusable for verification |
mrz_viz_mismatch | err | Checksum-valid MRZ contradicts the printed fields — a forgery signal |
aml_sanctions_match | err | Strong 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.
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.
| Key | Example |
|---|---|
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
- Examples — full handler implementations
- Signature verification — algorithm reference
- Retries — delivery guarantees
- Webhooks — back to the section index