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

# Webhooks

> Real-time event notifications, signature verification, and retry behavior.

Webhooks notify your server in real time when events occur in Verifa. Configure
a webhook URL in your organization settings to start receiving events.

## Event types

For the complete event catalog with payload examples, see the
[Webhooks Guide](/integration-guides/webhooks-guide) and
[Handling Webhook Events](/tutorials/handling-webhook-events) tutorial.

| Event                            | Description                                                           |
| -------------------------------- | --------------------------------------------------------------------- |
| `session.created`                | A new verification session was created.                               |
| `session.started`                | The user opened the capture URL and began the flow.                   |
| `session.expired`                | The session token expired before completion.                          |
| `session.requires-review`        | The session needs manual human review.                                |
| `session.requires-second-review` | The session requires a second reviewer (dual review).                 |
| `session.approved`               | The verification was approved (auto or manual).                       |
| `session.declined`               | The verification was rejected.                                        |
| `session.updated`                | Session external_ref or metadata was updated.                         |
| `session.submitted`              | Session submitted for processing after document capture.              |
| `session.resumed`                | Session token regenerated via resume endpoint.                        |
| `session.awaiting_external`      | Workflow paused at an external gate.                                  |
| `session.resubmission-required`  | Image quality insufficient; user must re-submit.                      |
| `session.redacted`               | Session PII and documents were permanently deleted.                   |
| `session.retention-expired`      | Retention window elapsed; data was auto-redacted.                     |
| `session.link_generated`         | A shareable verification link was created.                            |
| `check.completed`                | A screening check finished processing.                                |
| `check.matched`                  | A screening check completed with one or more hits.                    |
| `check.monitoring.new_hits`      | Continuous monitoring discovered new matches.                         |
| `check.monitoring.clear`         | Monitoring re-check completed with no new hits.                       |
| `identity.created`               | A new identity record was created.                                    |
| `identity.updated`               | An existing identity record was updated.                              |
| `identity.flagged`               | An identity was flagged for review.                                   |
| `identity.archived`              | An identity was archived.                                             |
| `identity.restored`              | An archived identity was restored.                                    |
| `identity.merged`                | Two identity records were merged.                                     |
| `identity.redacted`              | Identity PII was permanently deleted.                                 |
| `identity.tag-added`             | A tag was added to an identity.                                       |
| `identity.tag-removed`           | A tag was removed from an identity.                                   |
| `identity.relationship.created`  | A relationship was created between identities.                        |
| `identity.relationship.deleted`  | A relationship was deleted between identities.                        |
| `consortium.match_found`         | Consortium matching detected a cross-org face match (Verifa Network). |
| `case.created`                   | A review case was created.                                            |
| `case.claimed`                   | A reviewer claimed a case.                                            |
| `case.approved`                  | A case was approved.                                                  |
| `case.rejected`                  | A case was rejected.                                                  |
| `case.escalated`                 | A case was escalated to a higher reviewer.                            |
| `document.uploaded`              | A document was uploaded.                                              |
| `document.classified`            | Document classification completed.                                    |
| `document.extracted`             | Field extraction completed on a document.                             |
| `document.verified`              | All verification checks completed on a document.                      |
| `document.compared`              | A guided extraction comparison completed.                             |
| `document.redacted`              | A document was permanently deleted.                                   |
| `smart_qa.finding.created`       | Smart QA generated a finding.                                         |
| `smart_qa.finding.critical`      | Smart QA generated a critical-severity finding.                       |

## Payload format

Every payload uses the same **envelope**: `event`, `created_at`, and
`idempotency_key` sit at the top level, and all event-specific fields are
nested under `data`. Route on `event`, then read the resource fields from
`data` (e.g. `body.data.session_id`).

```json
{
  "event": "session.approved",
  "created_at": "2026-02-01T12:05:00Z",
  "idempotency_key": "idk_a1b2c3d4e5f6...",
  "data": {
    "session_id": "session_abc123",
    "external_ref": "user_abc123",
    "status": "approved",
    "is_sandbox": false
  }
}
```

The `data` contents vary by event type — see the event reference below and the
[Handling webhook events](/tutorials/handling-webhook-events) tutorial.

### `identity.flagged`

Fires when an identity is flagged for review. The payload intentionally omits
the reviewer email and free-text reason — fetch them from
`GET /identities/{identity_id}` if your subscriber has the right scopes.

```json
{
  "event": "identity.flagged",
  "created_at": "2026-02-01T12:05:00Z",
  "data": {
    "identity_id": "idn_abc123",
    "session_id": "ses_def456",
    "has_reason": true,
    "is_sandbox": false
  }
}
```

| Field         | Type           | Description                                                                            |
| ------------- | -------------- | -------------------------------------------------------------------------------------- |
| `identity_id` | string         | The identity that was flagged                                                          |
| `session_id`  | string or null | The session that triggered the flag, if any                                            |
| `has_reason`  | boolean        | `true` when a reason was recorded; the reason itself is no longer included for privacy |
| `is_sandbox`  | boolean        | Whether the identity is in sandbox mode                                                |

### `consortium.match_found`

Fires when consortium matching detects a face match for the same individual
across two organizations participating in the Verifa Network. Both involved
organizations receive the event.

```json
{
  "event": "consortium.match_found",
  "created_at": "2026-02-01T12:05:00Z",
  "data": {
    "consortium_id": "cgrp_abc123",
    "matched_session_id": "ses_def456",
    "primary_session_id": "ses_abc123",
    "similarity": 0.94,
    "ghost_student_score": 78,
    "ghost_student_tier": "high",
    "is_sandbox": false
  }
}
```

| Field                 | Type    | Description                                         |
| --------------------- | ------- | --------------------------------------------------- |
| `consortium_id`       | string  | The consortium where the match was found            |
| `matched_session_id`  | string  | The previously seen session that matched            |
| `primary_session_id`  | string  | The session being verified that triggered the match |
| `similarity`          | float   | Face-embedding cosine similarity (0–1)              |
| `ghost_student_score` | integer | Ghost-student risk score (0–100)                    |
| `ghost_student_tier`  | string  | One of `low`, `medium`, `high`, `critical`          |
| `is_sandbox`          | boolean | Whether either session is in sandbox mode           |

## Signature verification

Every webhook request includes an `X-Verifa-Signature` header in the
Stripe-style format `t=<unix_ts>,v1=<hex_hmac>`:

* `t` — Unix timestamp the signature was generated at
* `v1` — hex HMAC-SHA256 of `f"{t}.{raw_request_body}"`, signed with your
  endpoint's `whsec_*` secret

Always verify the signature **and** reject deliveries with a `t=` timestamp
more than 5 minutes from your current clock to prevent replay attacks. See
[Tutorial: Receiving Webhooks & Validating Signatures](/tutorials/receiving-webhooks-validating-signatures)
for the full reference recipe with end-to-end code samples.

> **Note**
>
> The legacy bare-hex HMAC over the raw body is still accepted by the
> verification helper (`src/core/security.py:verify_webhook_signature`) for
> in-flight deliveries during the deprecation window. New outbound deliveries
> always use the timestamped format.

#### Node.js

```javascript
const crypto = require("crypto");
const TOLERANCE = 300; // 5-minute replay window

function verifyWebhookSignature(body, header, secret) {
  const parts = Object.fromEntries(
    (header || "").split(",").map((p) => {
      const i = p.indexOf("=");
      return i > 0 ? [p.slice(0, i), p.slice(i + 1)] : [p, ""];
    })
  );
  const ts = parseInt(parts.t || "0", 10);
  const v1 = parts.v1 || "";
  if (!ts || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE) return false;

  const signed = Buffer.concat([Buffer.from(`${ts}.`), body]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signed)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// In your webhook handler:
const isValid = verifyWebhookSignature(
  req.rawBody,
  req.headers["x-verifa-signature"],
  process.env.VERIFA_WEBHOOK_SECRET
);
```

#### Python

```python
import hashlib
import hmac
import time

TOLERANCE = 300  # 5-minute replay window


def verify_webhook_signature(body: bytes, header: str, secret: str) -> bool:
    parts = dict(
        p.split("=", 1) for p in (header or "").split(",") if "=" in p
    )
    try:
        ts = int(parts.get("t", "0"))
    except ValueError:
        return False
    v1 = parts.get("v1", "")
    if not ts or not v1:
        return False
    if abs(int(time.time()) - ts) > TOLERANCE:
        return False

    signed = f"{ts}.".encode() + body
    expected = hmac.new(
        secret.encode(), signed, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)


# In your webhook handler:
is_valid = verify_webhook_signature(
    request.body,
    request.headers["X-Verifa-Signature"],
    os.environ["VERIFA_WEBHOOK_SECRET"],
)
```

## Retry behavior

If your endpoint returns a non-2xx status code or does not respond within 30
seconds, Verifa retries the delivery with exponential backoff:

| Attempt   | Delay        |
| --------- | ------------ |
| 1st retry | \~30 seconds |
| 2nd retry | \~2 minutes  |
| 3rd retry | \~8 minutes  |
| 4th retry | \~32 minutes |
| 5th retry | \~2 hours    |

After 5 failed retries, the event is marked as failed. Failed events can be
viewed in the dashboard.

## Testing webhooks

Use the test endpoint to send a test event to your configured URL:

```bash
curl -X POST https://api.withverifa.com/api/v1/webhooks/endpoints/webhook_abc123/test \
  -H "X-API-Key: vk_live_your_key_here"
```

```json
{
  "success": true,
  "http_status": 200,
  "url": "https://your-app.com/webhooks/verifa"
}
```

## Best practices

* **Respond quickly.** Return a `200` status code as soon as you receive the
  event. Process the event asynchronously.
* **Handle duplicates.** In rare cases (network issues, retries), you may
  receive the same event more than once. Use the `session_id` to deduplicate.
* **Verify signatures.** Always validate the `X-Verifa-Signature` header to
  prevent spoofed events.

## Related

* [Webhooks Guide](/integration-guides/webhooks-guide) — Comprehensive setup guide with code
  examples in 7 languages
* [Errors](/api-details/errors) — Error codes returned by webhook API endpoints
* [Sessions](/core-concepts/sessions) — Session events and lifecycle