Reference

Events

The KYC session and application event types, and exactly when each fires.

Two families of events reach your webhook endpoint: ThirdFactor Verify session events and application workflow events. Route on the event_type field, which delivery storage adds to every payload. This page lists every event_type in use today, a full example payload for each, and exactly which status transition produces it.

Info

Delivery is at-least-once and best-effort from the application/session side — see Webhooks overview for the retry schedule and idempotency guidance. This page is scoped to which events exist and when they fire.

Note

Looking for finer-grained events — per-module results, AML hits, review-case movement, session created/started, credit alerts? Those ship through Connect endpoints with their own event keys and envelope — see the Connect event catalog. This page covers the classic tenant webhook's event families only.

KYC session events

ThirdFactor Verify sessions (/v3) emit one of these event_type values:

event_typestatusFires when
identity.kyc.session.approvedapprovedThe session passed every enabled module, or an operator approved it in review.
identity.kyc.session.declineddeclinedA module or a risk rule declined the session, or an operator declined it in review.
identity.kyc.session.reviewin_reviewThe session is held for manual review (for example, an AML hit or a low-confidence check).
identity.kyc.session.abandonedabandonedThe applicant started but the hosted URL lapsed before they finished.
identity.kyc.session.expiredexpiredThe hosted URL lapsed before the applicant started.

Every event carries the same body: the session's core fields at the top level — branch on status, identify the user by vendor_data — plus session, the complete session exactly as GET /v3/session/<id>/ returns it at that moment: every module's data (session.modules), hosted URLs for every evidence file (session.media), the extracted identity, the risk profile, the network / device trail and any duplicates. See the KYC session payload for every field.

session also says how the applicant used Back (additive): allow_back (the workflow's Allow applicants to go back setting, pinned when the session was created), back_count (how often they pressed Back) and edited (they changed a step after first submitting it). Each session.modules[] row adds step_status (active, completed, superseded — finished, then dropped from the path by a branch change after an edit, data kept for audit — or skipped, never on the path), version (an input step's data version, 1 as first submitted; null for ID, liveness and other non-input steps) and edited.

{
  "session_id": "6f0e2b7a-1234-4a11-9c22-0a5f7d1e9b30",
  "status": "approved",
  "decision": {
    "ID_VERIFICATION": { "feature": "ID_VERIFICATION", "status": "approved", "score": 98, "warnings": [], "data": { "document_type": "passport", "fields": { "…": "…" } } },
    "LIVENESS": { "feature": "LIVENESS", "status": "approved", "score": 91.2, "warnings": [], "data": { "method": "PASSIVE", "passed": true } },
    "FACE_MATCH": { "feature": "FACE_MATCH", "status": "approved", "score": 94, "warnings": [], "data": { "matched": true, "similarity": 94 } }
  },
  "decision_reason": "All checks passed",
  "vendor_data": "your-user-reference-123",
  "workflow_id": "9c1f4b2e-88aa-4a0d-9e11-2c7b6a0f5d40",
  "application_id": null,
  "created_at": "2026-07-03T08:00:00+00:00",
  "completed_at": "2026-07-03T08:06:12+00:00",
  "event_type": "identity.kyc.session.approved",
  "idempotency_key": "b2a1c9d0-6e5f-4a3b-8c7d-1e2f3a4b5c6d",
  "session": {
    "id": "6f0e2b7a-1234-4a11-9c22-0a5f7d1e9b30",
    "status": "approved",
    "modules": [{ "node_id": "ID_VERIFICATION", "feature": "ID_VERIFICATION", "status": "approved", "data": { "…": "…" },
                  "step_status": "completed", "version": null, "edited": false }],
    "media": [{ "kind": "document", "side": "front", "url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/…" }],
    "identity": { "full_name": "…" },
    "risk": { "score": 0, "level": "low", "reasons": [], "rules": [] },
    "network": { "ip_addresses": [], "precise_location": null, "clients": [] },
    "allow_back": true,
    "back_count": 0,
    "edited": false,
    "…": "…"
  }
}

Warning

.review is not terminal in the business sense. Expect a later .approved or .declined once an operator resolves the session in Console → Verifications. Model your state machine so a session can sit in review for hours and still settle. AML fails closed — if the screening service is unavailable, sessions land in review rather than passing silently.

Questionnaire submitted (masked fields)

event_typeFires when
identity.kyc.questionnaire.submittedAn applicant submits a questionnaire that contains a value for a field with field masking on.

It is the first webhook of that submission and the only place the real value of a masked field (a card number, say) is ever sent: its first delivery attempt carries the typed value ("unmasked": true), while the stored delivery — what a retry or a console resend sends — and every later event carry the masked value (at most the first four and last four characters). Forms without masking never produce this event. The body holds session_id, status, workflow_id, vendor_data, the submission (node_id, form_id, answers, questions, masked_fields, tags, version, edited) and submitted_at. version is 1 for the first submission; when the applicant goes Back and changes the answers, the changed submission fires the event again with the next version and "edited": true (the masked field is typed again for it). Continuing with nothing changed fires nothing. See Questionnaire → Field masking for the full example.

Note

A retry from the hosted flow's Failed result screen is a new session: it fires its own identity.kyc.session.* events, and its metadata.retry_of names the declined session. See Result screen & retries.

Application events

Applications (/api/v1) emit an event when they reach one of three terminal (or terminal-for-now) states. The legacy event string differs by outcome — this is a common source of confusion, so treat the table below, not a naming pattern, as the source of truth:

Application statusapplication.decisionevent_type firedPayload
completedapprovedidentity.verification.completedFull envelope
sdk_callback_receivedapprovedidentity.verification.completedFull envelope
kyc_failedrejectedidentity.verification.failedFull envelope
manual_reviewmanual_reviewidentity.application.manual_reviewFull envelope, blocklist_match populated when the trigger was a risk-list hit

Warning

This is exactly why the API reference and this doc both say branch on application.decision, not on the event / event_type string: an approved application's event is named identity.verification.completed, not identity.application.approved. Pattern-matching event_type.startsWith("identity.application.") will silently miss approvals and rejections.

{
  "payload_version": "2026-05-17",
  "event": "identity.verification.completed",
  "application": {
    "id": "ge9vgmlmfbi9k5q",
    "external_ref": "core-banking-id-123",
    "tracking_id": "APP-2026-0001",
    "status": "completed",
    "decision": "approved",
    "flow_id": "savings_v1",
    "current_step_id": "done",
    "created_at": "2026-05-18T08:00:00Z",
    "updated_at": "2026-05-18T08:12:40Z"
  },
  "form_data": {
    "full_name": "Aarav Sharma",
    "phone": "9841000000",
    "account_type": "savings"
  },
  "product": { "id": "savings-standard", "name": "Standard Savings" },
  "sdk_response": {
    "is_verified": true,
    "percentage_match": 96.4,
    "document_type": "citizenship"
  },
  "blocklist_match": null,
  "created_at": "2026-05-18T08:12:40Z",
  "event_type": "identity.verification.completed",
  "idempotency_key": "f6e5a3b4-a293-4e7f-a0b1-5c6d7e8f9012"
}

Note

Legacy event names still appear for backwards compatibility. New integrations should key off application.decision rather than pattern-matching event names.

Statuses that do not currently fire a webhook

Not every status transition is wired to a delivery. If you're waiting on an event that never arrives, check whether the status you're watching is on this list before assuming a delivery failure:

Status (session or application)Fires an event?
not_started, in_progress, awaiting_user, resubmitted (session)No — not terminal
draft, awaiting_form, form_started, form_filled, scheduled, sdk_redirected, visit_branch (application)No — not terminal
abandoned (application)No classic event fires. Poll GET /api/v1/applications/ with a status filter. (An abandoned session does fire identity.kyc.session.abandoned.)
kyc_expired (session)No event fires; distinct from expired (hosted-URL TTL), which does fire identity.kyc.session.expired.

Tip

When you receive a status/decision you do not recognise, treat it as in_review — hold the user in a pending state and wait for a terminal event. That is what the SDKs do, deliberately.

Flow-spawned sessions emit both

A ThirdFactor Verify session created by a hosted flow's identity step is linked to a parent application. When it settles it emits its own identity.kyc.session.* event and advances the parent application, which emits the usual application event from the table above. In that case the session payload's application_id is set.

If you subscribe to both families, deduplicate at the application level so you don't process the same customer twice — the two events have different idempotency_key values because they're genuinely different deliveries. For pure /v3 sessions there is no parent application, application_id is null, and only the identity.kyc.session.* event fires.

Retries and ordering

Delivery is at-least-once, which has two consequences:

  • Events can arrive more than once. Deduplicate on idempotency_key. See Webhooks overview for the exact backoff schedule.
  • Events can arrive out of order. Do not assume .review lands before .approved. Compare created_at, or re-read the current decision from GET /v3/session/<id>/decision/ when an event looks stale.

FAQ