> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.withverifa.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.withverifa.com/_mcp/server.

# Tutorial: Handling Webhook Events

> Learn which webhook events to subscribe to, what each payload contains, and how to build handlers for the most common integration scenarios.

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.

> **Note**
>
> This tutorial assumes you already have a working webhook endpoint that verifies
> signatures. If not, start with the
> [Receiving Webhooks & Validating Signatures](/tutorials/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 case                          | Events to subscribe                                                                             |
| --------------------------------- | ----------------------------------------------------------------------------------------------- |
| **Basic KYC onboarding**          | `session.approved`, `session.declined`                                                          |
| **KYC with manual review**        | `session.approved`, `session.declined`, `session.requires-review`                               |
| **AML compliance**                | `check.completed`, `check.monitoring.new_hits`                                                  |
| **Full lifecycle tracking**       | `session.created`, `session.started`, `session.approved`, `session.declined`, `session.expired` |
| **Document verification**         | `document.verified`, `document.extracted`                                                       |
| **Case management / dual review** | `case.created`, `case.approved`, `case.rejected`, `case.escalated`                              |
| **Identity management**           | `identity.created`, `identity.updated`                                                          |

## Event payload structure

Every webhook is delivered as an envelope. The event type and delivery
metadata live at the top level, and all event-specific fields are nested
under `data`:

```json
{
  "event": "session.approved",
  "created_at": "2026-02-01T12:05:00Z",
  "idempotency_key": "idk_a1b2c3d4e5f6...",
  "data": {
    ...event-specific fields
  }
}
```

* `event` — the event type string
* `created_at` — ISO 8601 timestamp of when the event was generated
* `idempotency_key` — unique delivery ID for deduplication (same key on retries)
* `data` — object holding the event-specific fields (documented per-event below)

The per-event examples below show only the `event` type and its `data`
payload; every delivery is wrapped in the full envelope shown above.

---

## 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.

```json
{
  "event": "session.approved",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "status": "approved",
    "is_sandbox": false,
    "identity_id": "identity_def789",
    "result": {
      "face_match_passed": true,
      "age_check_passed": true,
      "extracted_data": {
        "first_name": "Jane",
        "last_name": "Doe",
        "date_of_birth": "1990-05-15",
        "document_number": "AB1234567",
        "document_type": "passport",
        "issuing_country": "US"
      }
    }
  }
}
```

**Always present:**

| Field          | Type           | Description                                    |
| -------------- | -------------- | ---------------------------------------------- |
| `session_id`   | string         | The session that was approved                  |
| `external_ref` | string or null | Your reference ID (passed at session creation) |
| `status`       | string         | Always `"approved"`                            |
| `is_sandbox`   | boolean        | Whether this is a sandbox session              |

**Included on most paths (treat as optional):**

| Field         | Type             | Description                            |
| ------------- | ---------------- | -------------------------------------- |
| `identity_id` | string or null   | The linked identity record, if created |
| `result`      | object or absent | Verification 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)

> **Warning**
>
> 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](/api-reference/sessions/get-session).

**Typical handler:**

#### Node.js

```javascript
case "session.approved":
  const { session_id, external_ref, identity_id, result } = event.data;

  // Activate the user's account
  await db.users.update(
    { verificationRef: external_ref },
    {
      verified: true,
      identityId: identity_id,
      verifiedAt: new Date(),
    }
  );

  // Store extracted data if available
  if (result?.extracted_data) {
    await db.users.update(
      { verificationRef: external_ref },
      {
        firstName: result.extracted_data.first_name,
        lastName: result.extracted_data.last_name,
        dateOfBirth: result.extracted_data.date_of_birth,
      }
    );
  }

  // Send welcome email
  await sendEmail(external_ref, "verification_approved");
  break;
```

#### Python

```python
if event_type == "session.approved":
    data = event["data"]
    session_id = data["session_id"]
    external_ref = data["external_ref"]
    identity_id = data.get("identity_id")
    result = data.get("result", {})

    # Activate the user's account
    await db.users.update(
        {"verification_ref": external_ref},
        verified=True,
        identity_id=identity_id,
        verified_at=datetime.utcnow(),
    )

    # Store extracted data if available
    extracted = result.get("extracted_data", {})
    if extracted:
        await db.users.update(
            {"verification_ref": external_ref},
            first_name=extracted.get("first_name"),
            last_name=extracted.get("last_name"),
            date_of_birth=extracted.get("date_of_birth"),
        )

    # Send welcome email
    await send_email(external_ref, "verification_approved")
```

#### Go

```go
case "session.approved":
    externalRef, _ := data["external_ref"].(string)
    identityID, _ := data["identity_id"].(string)

    // Activate the user's account
    db.Users.Update(externalRef, map[string]interface{}{
        "verified":    true,
        "identity_id": identityID,
        "verified_at": time.Now(),
    })

    // Store extracted data if available
    if result, ok := data["result"].(map[string]interface{}); ok {
        if extracted, ok := result["extracted_data"].(map[string]interface{}); ok {
            db.Users.Update(externalRef, map[string]interface{}{
                "first_name":    extracted["first_name"],
                "last_name":     extracted["last_name"],
                "date_of_birth": extracted["date_of_birth"],
            })
        }
    }

    // Send welcome email
    sendEmail(externalRef, "verification_approved")
```

---

### session.declined

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

```json
{
  "event": "session.declined",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "status": "rejected",
    "is_sandbox": false,
    "rejection_reason": "Document appears to be altered"
  }
}
```

**Always present:**

| Field          | Type           | Description                       |
| -------------- | -------------- | --------------------------------- |
| `session_id`   | string         | The session that was rejected     |
| `external_ref` | string or null | Your reference ID                 |
| `status`       | string         | Always `"rejected"`               |
| `is_sandbox`   | boolean        | Whether this is a sandbox session |

**Included on some paths (treat as optional):**

| Field              | Type             | Description                                                         |
| ------------------ | ---------------- | ------------------------------------------------------------------- |
| `rejection_reason` | string or null   | Reason provided by the reviewer (manual rejections only)            |
| `identity_id`      | string or null   | Linked identity, if one exists                                      |
| `result`           | object or absent | Partial verification results (some auto-decline paths include this) |

**Typical handler:**

#### Node.js

```javascript
case "session.declined":
  await db.users.update(
    { verificationRef: event.data.external_ref },
    { verificationStatus: "rejected" }
  );

  // Notify the user with instructions to retry
  await sendEmail(event.data.external_ref, "verification_declined", {
    reason: event.data.rejection_reason ?? null,
  });
  break;
```

#### Python

```python
if event_type == "session.declined":
    data = event["data"]
    await db.users.update(
        {"verification_ref": data["external_ref"]},
        verification_status="rejected",
    )

    # Notify the user with instructions to retry
    await send_email(
        data["external_ref"],
        "verification_declined",
        reason=data.get("rejection_reason"),
    )
```

#### Go

```go
case "session.declined":
    externalRef, _ := data["external_ref"].(string)
    rejectionReason, _ := data["rejection_reason"].(string)

    db.Users.Update(externalRef, map[string]interface{}{
        "verification_status": "rejected",
    })

    // Notify the user with instructions to retry
    sendEmail(externalRef, "verification_declined", rejectionReason)
```

---

### session.requires-review

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

```json
{
  "event": "session.requires-review",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

| Field          | Type           | Description                       |
| -------------- | -------------- | --------------------------------- |
| `session_id`   | string         | The session awaiting review       |
| `external_ref` | string or null | Your reference ID                 |
| `is_sandbox`   | boolean        | Whether this is a sandbox session |

**Typical handler:**

#### Node.js

```javascript
case "session.requires-review":
  await db.users.update(
    { verificationRef: event.data.external_ref },
    { verificationStatus: "pending_review" }
  );

  // Notify your compliance team
  await slack.send("#compliance", {
    text: `Session ${event.data.session_id} needs manual review`,
  });
  break;
```

#### Python

```python
if event_type == "session.requires-review":
    data = event["data"]
    await db.users.update(
        {"verification_ref": data["external_ref"]},
        verification_status="pending_review",
    )

    # Notify your compliance team
    await slack.send(
        "#compliance",
        f"Session {data['session_id']} needs manual review",
    )
```

#### Go

```go
case "session.requires-review":
    externalRef, _ := data["external_ref"].(string)
    sessionID, _ := data["session_id"].(string)

    db.Users.Update(externalRef, map[string]interface{}{
        "verification_status": "pending_review",
    })

    // Notify your compliance team
    slack.Send("#compliance",
        fmt.Sprintf("Session %s needs manual review", sessionID))
```

---

### session.created

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

```json
{
  "event": "session.created",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

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.

```json
{
  "event": "session.started",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

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.

```json
{
  "event": "session.expired",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

**Typical handler:**

#### Node.js

```javascript
case "session.expired":
  // Send reminder to the user to complete verification
  await sendEmail(event.data.external_ref, "verification_expired");
  break;
```

#### Python

```python
if event_type == "session.expired":
    data = event["data"]
    # Send reminder to the user to complete verification
    await send_email(data["external_ref"], "verification_expired")
```

#### Go

```go
case "session.expired":
    externalRef, _ := data["external_ref"].(string)

    // Send reminder to the user to complete verification
    sendEmail(externalRef, "verification_expired")
```

---

### session.resubmission-required

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

```json
{
  "event": "session.resubmission-required",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "is_sandbox": false,
    "reason": "Document image is too blurry to extract data"
  }
}
```

| Field    | Type   | Description                                              |
| -------- | ------ | -------------------------------------------------------- |
| `reason` | string | Human-readable explanation of why resubmission is needed |

---

### session.redacted

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

```json
{
  "event": "session.redacted",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

---

### session.retention-expired

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

```json
{
  "event": "session.retention-expired",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

---

## 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.

```json
{
  "event": "check.completed",
  "data": {
    "check_id": "check_abc123",
    "check_type": "sanction",
    "status": "clear",
    "has_match": false,
    "hit_count": 0,
    "batch_id": "batch_def456",
    "session_id": "session_ghi789",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

**Always present:**

| Field        | Type    | Description                                                                                                |
| ------------ | ------- | ---------------------------------------------------------------------------------------------------------- |
| `check_id`   | string  | The check that completed                                                                                   |
| `check_type` | string  | `"sanction"`, `"pep"`, `"adverse_media"`, `"warning"`, `"email_risk_enhanced"`, or `"phone_risk_enhanced"` |
| `status`     | string  | `"hit"` or `"clear"`                                                                                       |
| `has_match`  | boolean | Whether any matches were found                                                                             |
| `is_sandbox` | boolean | Whether this is a sandbox check                                                                            |

**Included on most paths (treat as optional):**

| Field          | Type           | Description                                                   |
| -------------- | -------------- | ------------------------------------------------------------- |
| `hit_count`    | integer        | Number of matches found (absent on some risk check paths)     |
| `batch_id`     | string         | The batch this check belongs to (session-linked checks only)  |
| `session_id`   | string or null | Linked session (absent for standalone API checks)             |
| `external_ref` | string or null | Your reference ID                                             |
| `max_score`    | float          | Highest match confidence score (only present when hits found) |

For PEP checks with hits, additional fields are included:

```json
{
  "check_type": "pep",
  "pep_risk_tier": "high",
  "pep_recommended_action": "review",
  "pep_classes": ["head-of-state", "government-minister"]
}
```

---

### 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.

```json
{
  "event": "check.matched",
  "data": {
    "check_id": "check_abc123",
    "check_type": "sanction",
    "status": "hit",
    "hit_count": 2,
    "batch_id": "batch_def456",
    "session_id": "session_ghi789",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

**Typical handler:**

#### Node.js

```javascript
case "check.matched":
  // Flag the user for compliance review
  await db.complianceFlags.create({
    userId: event.data.external_ref,
    checkId: event.data.check_id,
    checkType: event.data.check_type,
    hitCount: event.data.hit_count,
    flaggedAt: new Date(),
  });

  // Alert compliance team
  await slack.send("#compliance-alerts", {
    text: `AML ${event.data.check_type} match: ${event.data.hit_count} hit(s) for ${event.data.external_ref}`,
  });
  break;
```

#### Python

```python
if event_type == "check.matched":
    data = event["data"]
    # Flag the user for compliance review
    await db.compliance_flags.create(
        user_id=data["external_ref"],
        check_id=data["check_id"],
        check_type=data["check_type"],
        hit_count=data["hit_count"],
        flagged_at=datetime.utcnow(),
    )

    # Alert compliance team
    await slack.send(
        "#compliance-alerts",
        f"AML {data['check_type']} match: "
        f"{data['hit_count']} hit(s) for {data['external_ref']}",
    )
```

#### Go

```go
case "check.matched":
    externalRef, _ := data["external_ref"].(string)
    checkID, _ := data["check_id"].(string)
    checkType, _ := data["check_type"].(string)
    hitCount, _ := data["hit_count"].(float64)

    // Flag the user for compliance review
    db.ComplianceFlags.Create(map[string]interface{}{
        "user_id":    externalRef,
        "check_id":   checkID,
        "check_type": checkType,
        "hit_count":  int(hitCount),
        "flagged_at": time.Now(),
    })

    // Alert compliance team
    slack.Send("#compliance-alerts",
        fmt.Sprintf("AML %s match: %d hit(s) for %s",
            checkType, int(hitCount), externalRef))
```

---

### check.monitoring.new_hits

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

```json
{
  "event": "check.monitoring.new_hits",
  "data": {
    "check_id": "check_abc123",
    "session_id": "session_ghi789",
    "external_ref": "user_456",
    "is_sandbox": false,
    "monitor_run_id": "monrun_xyz",
    "trigger": "scheduled",
    "new_hit_count": 1,
    "total_hit_count": 3,
    "max_new_score": 0.92,
    "hit_sources": ["aml_screening"],
    "hit_types": ["sanction"],
    "providers_used": ["aml_screening"]
  }
}
```

| Field             | Type            | Description                                                                                      |
| ----------------- | --------------- | ------------------------------------------------------------------------------------------------ |
| `monitor_run_id`  | string          | ID of this monitoring run                                                                        |
| `trigger`         | string          | What triggered the rescreen (`"scheduled"`, `"on_demand"`, `"ca_webhook"`, `"watchlist_change"`) |
| `new_hit_count`   | integer         | Number of new matches found in this run                                                          |
| `total_hit_count` | integer         | Total matches across all runs                                                                    |
| `max_new_score`   | float           | Highest confidence score among new hits                                                          |
| `hit_sources`     | array           | Sources that found matches (e.g., `["aml_screening"]`)                                           |
| `hit_types`       | array or absent | Check types with hits (e.g., `["sanction", "pep"]`). May be absent on some provider paths.       |
| `providers_used`  | array           | Screening providers used                                                                         |

**Typical handler:**

#### Node.js

```javascript
case "check.monitoring.new_hits":
  // Escalate to compliance
  await db.complianceAlerts.create({
    userId: event.data.external_ref,
    alertType: "monitoring_hit",
    newHitCount: event.data.new_hit_count,
    maxScore: event.data.max_new_score,
    hitTypes: event.data.hit_types,
    monitorRunId: event.data.monitor_run_id,
  });

  // Consider restricting user access pending review
  if (event.data.hit_types?.includes("sanction")) {
    await db.users.update(
      { verificationRef: event.data.external_ref },
      { accountStatus: "restricted" }
    );
  }
  break;
```

#### Python

```python
if event_type == "check.monitoring.new_hits":
    data = event["data"]
    # Escalate to compliance
    await db.compliance_alerts.create(
        user_id=data["external_ref"],
        alert_type="monitoring_hit",
        new_hit_count=data["new_hit_count"],
        max_score=data["max_new_score"],
        hit_types=data.get("hit_types", []),
        monitor_run_id=data["monitor_run_id"],
    )

    # Consider restricting user access pending review
    if "sanction" in data.get("hit_types", []):
        await db.users.update(
            {"verification_ref": data["external_ref"]},
            account_status="restricted",
        )
```

#### Go

```go
case "check.monitoring.new_hits":
    externalRef, _ := data["external_ref"].(string)
    newHitCount, _ := data["new_hit_count"].(float64)
    maxScore, _ := data["max_new_score"].(float64)
    monitorRunID, _ := data["monitor_run_id"].(string)
    hitTypes, _ := data["hit_types"].([]interface{})

    // Escalate to compliance
    db.ComplianceAlerts.Create(map[string]interface{}{
        "user_id":        externalRef,
        "alert_type":     "monitoring_hit",
        "new_hit_count":  int(newHitCount),
        "max_score":      maxScore,
        "monitor_run_id": monitorRunID,
    })

    // Consider restricting user access pending review
    for _, ht := range hitTypes {
        if ht == "sanction" {
            db.Users.Update(externalRef, map[string]interface{}{
                "account_status": "restricted",
            })
            break
        }
    }
```

---

### check.monitoring.clear

Fired when a monitoring rescreen completes with no new matches.

```json
{
  "event": "check.monitoring.clear",
  "data": {
    "check_id": "check_abc123",
    "session_id": "session_ghi789",
    "external_ref": "user_456",
    "is_sandbox": false,
    "monitor_run_id": "monrun_xyz",
    "trigger": "scheduled",
    "total_hit_count": 2,
    "hit_sources": ["aml_screening"],
    "hit_types": ["pep"],
    "providers_used": ["aml_screening"]
  }
}
```

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).

```json
{
  "event": "identity.created",
  "data": {
    "identity_id": "identity_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

### identity.updated

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

```json
{
  "event": "identity.updated",
  "data": {
    "identity_id": "identity_abc123",
    "external_ref": "user_456",
    "is_sandbox": false
  }
}
```

> **Note**
>
> 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:

```json
{
  "event": "case.approved",
  "data": {
    "case_id": "case_abc123",
    "session_id": "session_def456",
    "external_ref": "user_456",
    "status": "approved",
    "decision": "approved",
    "is_sandbox": false
  }
}
```

| Field          | Type           | Description                                      |
| -------------- | -------------- | ------------------------------------------------ |
| `case_id`      | string         | The review case ID                               |
| `session_id`   | string         | The session under review                         |
| `external_ref` | string or null | Your reference ID                                |
| `status`       | string         | Current case status                              |
| `decision`     | string         | The decision made (for approved/rejected events) |
| `is_sandbox`   | boolean        | Whether this is a sandbox case                   |

> **Note**
>
> 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

```json
{
  "event": "document.uploaded",
  "data": {
    "document_id": "doc_abc123",
    "document_type": "passport",
    "classification": "identity_document",
    "session_id": "session_def456",
    "identity_id": "identity_ghi789"
  }
}
```

### document.classified

```json
{
  "event": "document.classified",
  "data": {
    "document_id": "doc_abc123",
    "classification": "passport",
    "confidence": 0.97
  }
}
```

### document.extracted

```json
{
  "event": "document.extracted",
  "data": {
    "document_id": "doc_abc123",
    "extraction_id": "extract_def456",
    "extraction_type": "ocr",
    "status": "completed"
  }
}
```

### document.verified

```json
{
  "event": "document.verified",
  "data": {
    "document_id": "doc_abc123",
    "checks_run": 4,
    "checks_passed": 3,
    "checks_failed": 1
  }
}
```

| Field           | Type    | Description                   |
| --------------- | ------- | ----------------------------- |
| `checks_run`    | integer | Total verification checks run |
| `checks_passed` | integer | Number of checks that passed  |
| `checks_failed` | integer | Number of checks that failed  |

### document.compared

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

```json
{
  "event": "document.compared",
  "data": {
    "document_id": "doc_abc123",
    "reference_document_id": "doc_xyz789",
    "is_match": true,
    "overall_score": 0.94
  }
}
```

### document.redacted

```json
{
  "event": "document.redacted",
  "data": {
    "document_id": "doc_abc123"
  }
}
```

---

## Building a complete handler

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

#### Node.js

```javascript
function handleEvent(event) {
  switch (event.event) {
    // === Session outcomes ===
    case "session.approved": {
      const { external_ref, identity_id, result } = event.data;
      activateUser(external_ref, identity_id, result?.extracted_data);
      sendEmail(external_ref, "verification_approved");
      break;
    }

    case "session.declined": {
      declineUser(event.data.external_ref);
      sendEmail(event.data.external_ref, "verification_declined", {
        reason: event.data.rejection_reason,
      });
      break;
    }

    case "session.requires-review": {
      setUserStatus(event.data.external_ref, "pending_review");
      notifyComplianceTeam(event.data.session_id);
      break;
    }

    // === Ongoing monitoring ===
    case "check.monitoring.new_hits": {
      createComplianceAlert(event.data);
      if (event.data.hit_types?.includes("sanction")) {
        restrictUser(event.data.external_ref);
      }
      break;
    }

    // === Lifecycle tracking ===
    case "session.expired": {
      sendEmail(event.data.external_ref, "verification_reminder");
      break;
    }

    default:
      console.log(`Unhandled event: ${event.event}`);
  }
}
```

#### Python

```python
async def handle_event(event: dict):
    event_type = event["event"]
    data = event.get("data", {})

    # === Session outcomes ===
    if event_type == "session.approved":
        result = data.get("result", {})
        await activate_user(
            data["external_ref"],
            data.get("identity_id"),
            result.get("extracted_data"),
        )
        await send_email(data["external_ref"], "verification_approved")

    elif event_type == "session.declined":
        await decline_user(data["external_ref"])
        await send_email(
            data["external_ref"],
            "verification_declined",
            reason=data.get("rejection_reason"),
        )

    elif event_type == "session.requires-review":
        await set_user_status(data["external_ref"], "pending_review")
        await notify_compliance_team(data["session_id"])

    # === Ongoing monitoring ===
    elif event_type == "check.monitoring.new_hits":
        await create_compliance_alert(data)
        if "sanction" in data.get("hit_types", []):
            await restrict_user(data["external_ref"])

    # === Lifecycle tracking ===
    elif event_type == "session.expired":
        await send_email(data["external_ref"], "verification_reminder")

    else:
        print(f"Unhandled event: {event_type}")
```

#### Go

```go
func handleEvent(event map[string]interface{}) {
    eventType, _ := event["event"].(string)
    data, _ := event["data"].(map[string]interface{})

    switch eventType {
    // === Session outcomes ===
    case "session.approved":
        externalRef, _ := data["external_ref"].(string)
        identityID, _ := data["identity_id"].(string)
        var extractedData map[string]interface{}
        if result, ok := data["result"].(map[string]interface{}); ok {
            extractedData, _ = result["extracted_data"].(map[string]interface{})
        }
        activateUser(externalRef, identityID, extractedData)
        sendEmail(externalRef, "verification_approved")

    case "session.declined":
        externalRef, _ := data["external_ref"].(string)
        reason, _ := data["rejection_reason"].(string)
        declineUser(externalRef)
        sendEmail(externalRef, "verification_declined", reason)

    case "session.requires-review":
        externalRef, _ := data["external_ref"].(string)
        sessionID, _ := data["session_id"].(string)
        setUserStatus(externalRef, "pending_review")
        notifyComplianceTeam(sessionID)

    // === Ongoing monitoring ===
    case "check.monitoring.new_hits":
        externalRef, _ := data["external_ref"].(string)
        hitTypes, _ := data["hit_types"].([]interface{})
        createComplianceAlert(data)
        for _, ht := range hitTypes {
            if ht == "sanction" {
                restrictUser(externalRef)
                break
            }
        }

    // === Lifecycle tracking ===
    case "session.expired":
        externalRef, _ := data["external_ref"].(string)
        sendEmail(externalRef, "verification_reminder")

    default:
        log.Printf("Unhandled event: %s", eventType)
    }
}
```

## 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](/api-reference/sessions/get-session) 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

* [Receiving Webhooks & Validating Signatures](/tutorials/receiving-webhooks-validating-signatures) — Set up your endpoint and verify signatures
* [Webhooks Guide](/integration-guides/webhooks-guide) — Event filtering, attribute blocklists, and key inflection
* [Webhooks API Reference](/api-details/webhooks) — Full event type list and retry behavior
* [Sessions](/core-concepts/sessions) — Session lifecycle and status reference
* [Screening & Reports](/core-concepts/screening-reports) — AML screening details