Tutorial: Handling Webhook Events

This tutorial covers the webhook events you’ll use in a real integration, what each payload looks like, and how to handle them. By the end, you’ll have a clear map of which events to subscribe to and how to react to each one.

This tutorial assumes you already have a working webhook endpoint that verifies signatures. If not, start with the Receiving Webhooks & Validating Signatures tutorial first.

The most important events

Most integrations need just three to five events. Here’s what to subscribe to based on your use case:

Use caseEvents to subscribe
Basic KYC onboardingsession.approved, session.declined
KYC with manual reviewsession.approved, session.declined, session.requires-review
AML compliancecheck.completed, check.monitoring.new_hits
Full lifecycle trackingsession.created, session.started, session.approved, session.declined, session.expired
Document verificationdocument.verified, document.extracted
Case management / dual reviewcase.created, case.approved, case.rejected, case.escalated
Identity managementidentity.created, identity.updated

Event payload structure

Every webhook payload follows the same top-level structure:

1{
2 "event": "session.approved",
3 "idempotency_key": "idk_a1b2c3d4e5f6...",
4 ...event-specific fields
5}
  • event — the event type string
  • idempotency_key — unique delivery ID for deduplication (same key on retries)
  • Remaining fields vary by event type (documented below)

Session events

Session events track the verification lifecycle from creation to final decision. These are the events most integrations are built around.

session.approved

Fired when a verification session passes — either automatically by the workflow engine or manually by a reviewer.

1{
2 "event": "session.approved",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "status": "approved",
6 "is_sandbox": false,
7 "identity_id": "identity_def789",
8 "result": {
9 "face_match_passed": true,
10 "age_check_passed": true,
11 "extracted_data": {
12 "first_name": "Jane",
13 "last_name": "Doe",
14 "date_of_birth": "1990-05-15",
15 "document_number": "AB1234567",
16 "document_type": "passport",
17 "issuing_country": "US"
18 }
19 }
20}

Always present:

FieldTypeDescription
session_idstringThe session that was approved
external_refstring or nullYour reference ID (passed at session creation)
statusstringAlways "approved"
is_sandboxbooleanWhether this is a sandbox session

Included on most paths (treat as optional):

FieldTypeDescription
identity_idstring or nullThe linked identity record, if created
resultobject or absentVerification results (see below)

The result object, when present, includes:

  • face_match_passed — whether the selfie matched the document photo
  • age_check_passed — whether the age verification passed
  • extracted_data — PII extracted from the document (present on sandbox and manual review paths; may be absent on the live engine path)

Not all approval paths include identity_id or result. Auto-retry completions and case API approvals send a minimal payload with only the guaranteed fields. Always use defensive access (e.g., .get() in Python, optional chaining in JavaScript) for these fields. If you always need extracted data, fall back to the Get Session API.

Typical handler:

1case "session.approved":
2 const { session_id, external_ref, identity_id, result } = event;
3
4 // Activate the user's account
5 await db.users.update(
6 { verificationRef: external_ref },
7 {
8 verified: true,
9 identityId: identity_id,
10 verifiedAt: new Date(),
11 }
12 );
13
14 // Store extracted data if available
15 if (result?.extracted_data) {
16 await db.users.update(
17 { verificationRef: external_ref },
18 {
19 firstName: result.extracted_data.first_name,
20 lastName: result.extracted_data.last_name,
21 dateOfBirth: result.extracted_data.date_of_birth,
22 }
23 );
24 }
25
26 // Send welcome email
27 await sendEmail(external_ref, "verification_approved");
28 break;

session.declined

Fired when a verification session is rejected — either automatically or by a reviewer.

1{
2 "event": "session.declined",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "status": "rejected",
6 "is_sandbox": false,
7 "rejection_reason": "Document appears to be altered"
8}

Always present:

FieldTypeDescription
session_idstringThe session that was rejected
external_refstring or nullYour reference ID
statusstringAlways "rejected"
is_sandboxbooleanWhether this is a sandbox session

Included on some paths (treat as optional):

FieldTypeDescription
rejection_reasonstring or nullReason provided by the reviewer (manual rejections only)
identity_idstring or nullLinked identity, if one exists
resultobject or absentPartial verification results (some auto-decline paths include this)

Typical handler:

1case "session.declined":
2 await db.users.update(
3 { verificationRef: event.external_ref },
4 { verificationStatus: "rejected" }
5 );
6
7 // Notify the user with instructions to retry
8 await sendEmail(event.external_ref, "verification_declined", {
9 reason: event.rejection_reason ?? null,
10 });
11 break;

session.requires-review

Fired when the workflow engine flags a session for manual review instead of making an automatic decision.

1{
2 "event": "session.requires-review",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}
FieldTypeDescription
session_idstringThe session awaiting review
external_refstring or nullYour reference ID
is_sandboxbooleanWhether this is a sandbox session

Typical handler:

1case "session.requires-review":
2 await db.users.update(
3 { verificationRef: event.external_ref },
4 { verificationStatus: "pending_review" }
5 );
6
7 // Notify your compliance team
8 await slack.send("#compliance", {
9 text: `Session ${event.session_id} needs manual review`,
10 });
11 break;

session.created

Fired when a new verification session is created via the API.

1{
2 "event": "session.created",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}

Useful for tracking session creation rates and linking sessions to users in your system before verification completes.


session.started

Fired when the applicant opens the capture URL and begins the verification flow.

1{
2 "event": "session.started",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}

Useful for measuring conversion from session creation to capture start.


session.expired

Fired when a session’s capture token expires before the applicant completes the flow.

1{
2 "event": "session.expired",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}

Typical handler:

1case "session.expired":
2 // Send reminder to the user to complete verification
3 await sendEmail(event.external_ref, "verification_expired");
4 break;

session.resubmission-required

Fired when a session is auto-rejected because the captured images were too low quality to process.

1{
2 "event": "session.resubmission-required",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false,
6 "reason": "Document image is too blurry to extract data"
7}
FieldTypeDescription
reasonstringHuman-readable explanation of why resubmission is needed

session.redacted

Fired when session PII and documents are permanently deleted (GDPR erasure or manual redaction).

1{
2 "event": "session.redacted",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}

session.retention-expired

Fired when session data is automatically purged by your organization’s retention policy.

1{
2 "event": "session.retention-expired",
3 "session_id": "session_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}

Check events

Check events are fired by standalone screening checks (AML, PEP, sanctions, email risk, phone risk). These are separate from session-based verification.

check.completed

Fired when a screening check finishes processing, regardless of whether it found matches.

1{
2 "event": "check.completed",
3 "check_id": "check_abc123",
4 "check_type": "sanction",
5 "status": "clear",
6 "has_match": false,
7 "hit_count": 0,
8 "batch_id": "batch_def456",
9 "session_id": "session_ghi789",
10 "external_ref": "user_456",
11 "is_sandbox": false
12}

Always present:

FieldTypeDescription
check_idstringThe check that completed
check_typestring"sanction", "pep", "adverse_media", "warning", "email_risk_enhanced", or "phone_risk_enhanced"
statusstring"hit" or "clear"
has_matchbooleanWhether any matches were found
is_sandboxbooleanWhether this is a sandbox check

Included on most paths (treat as optional):

FieldTypeDescription
hit_countintegerNumber of matches found (absent on some risk check paths)
batch_idstringThe batch this check belongs to (session-linked checks only)
session_idstring or nullLinked session (absent for standalone API checks)
external_refstring or nullYour reference ID
max_scorefloatHighest match confidence score (only present when hits found)

For PEP checks with hits, additional fields are included:

1{
2 "check_type": "pep",
3 "pep_risk_tier": "high",
4 "pep_recommended_action": "review",
5 "pep_classes": ["head-of-state", "government-minister"]
6}

check.matched

Fired alongside check.completed when a check finds one or more matches. Subscribe to this event if you only care about hits and want to skip clears.

1{
2 "event": "check.matched",
3 "check_id": "check_abc123",
4 "check_type": "sanction",
5 "status": "hit",
6 "hit_count": 2,
7 "batch_id": "batch_def456",
8 "session_id": "session_ghi789",
9 "external_ref": "user_456",
10 "is_sandbox": false
11}

Typical handler:

1case "check.matched":
2 // Flag the user for compliance review
3 await db.complianceFlags.create({
4 userId: event.external_ref,
5 checkId: event.check_id,
6 checkType: event.check_type,
7 hitCount: event.hit_count,
8 flaggedAt: new Date(),
9 });
10
11 // Alert compliance team
12 await slack.send("#compliance-alerts", {
13 text: `AML ${event.check_type} match: ${event.hit_count} hit(s) for ${event.external_ref}`,
14 });
15 break;

check.monitoring.new_hits

Fired when continuous monitoring discovers new matches on a previously screened individual. This is critical for ongoing AML compliance.

1{
2 "event": "check.monitoring.new_hits",
3 "check_id": "check_abc123",
4 "session_id": "session_ghi789",
5 "external_ref": "user_456",
6 "is_sandbox": false,
7 "monitor_run_id": "monrun_xyz",
8 "trigger": "scheduled",
9 "new_hit_count": 1,
10 "total_hit_count": 3,
11 "max_new_score": 0.92,
12 "hit_sources": ["aml_screening"],
13 "hit_types": ["sanction"],
14 "providers_used": ["aml_screening"]
15}
FieldTypeDescription
monitor_run_idstringID of this monitoring run
triggerstringWhat triggered the rescreen ("scheduled", "on_demand", "ca_webhook", "watchlist_change")
new_hit_countintegerNumber of new matches found in this run
total_hit_countintegerTotal matches across all runs
max_new_scorefloatHighest confidence score among new hits
hit_sourcesarraySources that found matches (e.g., ["aml_screening"])
hit_typesarray or absentCheck types with hits (e.g., ["sanction", "pep"]). May be absent on some provider paths.
providers_usedarrayScreening providers used

Typical handler:

1case "check.monitoring.new_hits":
2 // Escalate to compliance
3 await db.complianceAlerts.create({
4 userId: event.external_ref,
5 alertType: "monitoring_hit",
6 newHitCount: event.new_hit_count,
7 maxScore: event.max_new_score,
8 hitTypes: event.hit_types,
9 monitorRunId: event.monitor_run_id,
10 });
11
12 // Consider restricting user access pending review
13 if (event.hit_types?.includes("sanction")) {
14 await db.users.update(
15 { verificationRef: event.external_ref },
16 { accountStatus: "restricted" }
17 );
18 }
19 break;

check.monitoring.clear

Fired when a monitoring rescreen completes with no new matches.

1{
2 "event": "check.monitoring.clear",
3 "check_id": "check_abc123",
4 "session_id": "session_ghi789",
5 "external_ref": "user_456",
6 "is_sandbox": false,
7 "monitor_run_id": "monrun_xyz",
8 "trigger": "scheduled",
9 "total_hit_count": 2,
10 "hit_sources": ["aml_screening"],
11 "hit_types": ["pep"],
12 "providers_used": ["aml_screening"]
13}

Usually no action needed, but useful for audit logs and compliance reporting.


Identity events

Identity events fire when verified identity records are created or updated.

identity.created

Fired when a new identity record is created for the first time (typically after a session is approved).

1{
2 "event": "identity.created",
3 "identity_id": "identity_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}

identity.updated

Fired when an existing identity record is updated with data from a subsequent verification session.

1{
2 "event": "identity.updated",
3 "identity_id": "identity_abc123",
4 "external_ref": "user_456",
5 "is_sandbox": false
6}

The first session for an individual fires identity.created. Any subsequent sessions that match the same individual fire identity.updated.


Case events

Case events track the manual review lifecycle.

case.created / case.claimed / case.approved / case.rejected / case.escalated

All case events share the same payload structure:

1{
2 "event": "case.approved",
3 "case_id": "case_abc123",
4 "session_id": "session_def456",
5 "external_ref": "user_456",
6 "status": "approved",
7 "decision": "approved",
8 "is_sandbox": false
9}
FieldTypeDescription
case_idstringThe review case ID
session_idstringThe session under review
external_refstring or nullYour reference ID
statusstringCurrent case status
decisionstringThe decision made (for approved/rejected events)
is_sandboxbooleanWhether this is a sandbox case

When a case is approved or rejected, Verifa also fires the corresponding session.approved or session.declined event. You don’t need to subscribe to both unless you want case-specific metadata like case_id.


Document events

Document events track standalone document processing (outside of session-based verification).

document.uploaded

1{
2 "event": "document.uploaded",
3 "document_id": "doc_abc123",
4 "document_type": "passport",
5 "classification": "identity_document",
6 "session_id": "session_def456",
7 "identity_id": "identity_ghi789"
8}

document.classified

1{
2 "event": "document.classified",
3 "document_id": "doc_abc123",
4 "classification": "passport",
5 "confidence": 0.97
6}

document.extracted

1{
2 "event": "document.extracted",
3 "document_id": "doc_abc123",
4 "extraction_id": "extract_def456",
5 "extraction_type": "ocr",
6 "status": "completed"
7}

document.verified

1{
2 "event": "document.verified",
3 "document_id": "doc_abc123",
4 "checks_run": 4,
5 "checks_passed": 3,
6 "checks_failed": 1
7}
FieldTypeDescription
checks_runintegerTotal verification checks run
checks_passedintegerNumber of checks that passed
checks_failedintegerNumber of checks that failed

document.compared

Fired when a document is compared against another document or an identity record.

1{
2 "event": "document.compared",
3 "document_id": "doc_abc123",
4 "reference_document_id": "doc_xyz789",
5 "is_match": true,
6 "overall_score": 0.94
7}

document.redacted

1{
2 "event": "document.redacted",
3 "document_id": "doc_abc123"
4}

Building a complete handler

Here’s a full example that handles the most common events for a KYC onboarding integration:

1function handleEvent(event) {
2 switch (event.event) {
3 // === Session outcomes ===
4 case "session.approved": {
5 const { external_ref, identity_id, result } = event;
6 activateUser(external_ref, identity_id, result?.extracted_data);
7 sendEmail(external_ref, "verification_approved");
8 break;
9 }
10
11 case "session.declined": {
12 declineUser(event.external_ref);
13 sendEmail(event.external_ref, "verification_declined", {
14 reason: event.rejection_reason,
15 });
16 break;
17 }
18
19 case "session.requires-review": {
20 setUserStatus(event.external_ref, "pending_review");
21 notifyComplianceTeam(event.session_id);
22 break;
23 }
24
25 // === Ongoing monitoring ===
26 case "check.monitoring.new_hits": {
27 createComplianceAlert(event);
28 if (event.hit_types?.includes("sanction")) {
29 restrictUser(event.external_ref);
30 }
31 break;
32 }
33
34 // === Lifecycle tracking ===
35 case "session.expired": {
36 sendEmail(event.external_ref, "verification_reminder");
37 break;
38 }
39
40 default:
41 console.log(`Unhandled event: ${event.event}`);
42 }
43}

Tips for production

  • Use external_ref as your primary join key. It’s the reference ID you pass at session creation, so you can map events back to users in your system without storing Verifa session IDs.

  • Treat result as optional. Not all approval paths include the result object. If you need extracted data, fall back to the Get Session API when result is absent.

  • Subscribe to check.monitoring.new_hits if you do AML. This is the only way to get real-time alerts when ongoing monitoring finds new sanctions or PEP matches after the initial screening.

  • Don’t subscribe to everything. Use "*" only during development. In production, subscribe to the specific events you handle. This reduces noise and makes your handler simpler.

  • Log every event. Even if you don’t act on an event, log it for debugging and audit trails.

Next steps