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

> Set up, secure, and manage real-time event notifications.

Webhooks deliver real-time event notifications to your server when things happen
in Verifa — sessions complete, screening finds matches, identities update, and
more. This guide covers setup, security, event filtering, and best practices.

## Quick setup

### 1. Create a webhook endpoint

```bash
curl -X POST https://api.withverifa.com/api/v1/webhooks/endpoints \
  -H "X-API-Key: vk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/verifa",
    "enabled_events": ["session.approved", "session.declined", "session.requires-review"],
    "description": "Production webhook"
  }'
```

```json
{
  "id": "webhook_abc123",
  "url": "https://your-app.com/webhooks/verifa",
  "secret": "whsec_abc123def456...",
  "enabled_events": ["session.approved", "session.declined", "session.requires-review"],
  "enabled": true,
  "created_at": "2026-02-01T12:00:00Z"
}
```

**Store the `secret` securely** — you need it to verify webhook signatures. The
`whsec_*` value is shown **once** at creation and again only when you rotate
it. You cannot retrieve it later. Each endpoint has its own per-endpoint
secret, so rotating one endpoint never affects another.

> **Note**
>
> Per-endpoint signing secrets are mandatory for endpoints created on or after
> 2026-04-17. Endpoints created before that date may still fall back to the
> deprecated org-level webhook secret; rotate those endpoints to mint a
> dedicated `whsec_*` and stop relying on the org-wide value.

### 2. Handle events

The `X-Verifa-Signature` header uses the Stripe-style format
`t=<unix_ts>,v1=<hex_hmac>`. The HMAC-SHA256 covers `f"{t}.{raw_body}"` and
receivers should reject any delivery whose `t=` is more than 5 minutes from
current time (replay protection). See the
[Tutorial: Receiving Webhooks & Validating Signatures](/tutorials/receiving-webhooks-validating-signatures)
for full code samples and a reference recipe.

#### JavaScript

```javascript
const express = require("express");
const crypto = require("crypto");

const app = express();
const TOLERANCE = 300; // 5-minute replay window

function verify(rawBody, 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}.`), rawBody]);
  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);
}

app.post("/webhooks/verifa", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-verifa-signature"];
  if (!verify(req.body, signature, process.env.VERIFA_WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid or expired signature");
  }

  // Process event
  const event = JSON.parse(req.body);
  console.log(`Received ${event.event} for session ${event.data.session_id}`);

  // Respond immediately — process asynchronously
  res.status(200).send("OK");
  processEvent(event);
});
```

#### Python

```python
import hashlib
import hmac
import time

from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
TOLERANCE = 300  # 5-minute replay window


def verify(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)


@app.post("/webhooks/verifa")
async def handle_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("X-Verifa-Signature", "")

    if not verify(body, signature, WEBHOOK_SECRET):
        raise HTTPException(
            status_code=401, detail="Invalid or expired signature"
        )

    event = await request.json()
    # Queue for async processing
    process_event.delay(event)

    return {"status": "ok"}
```

### 3. Test it

Send a test event to verify your endpoint is working:

```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"
}
```

## Event catalog

### Session events

| Event                            | Description                                              |
| -------------------------------- | -------------------------------------------------------- |
| `session.created`                | A new verification session was created.                  |
| `session.started`                | The user opened the capture URL.                         |
| `session.expired`                | The capture token expired before completion.             |
| `session.approved`               | Verification approved (auto or manual).                  |
| `session.declined`               | Verification rejected.                                   |
| `session.requires-review`        | Session needs manual review.                             |
| `session.awaiting_external`      | Workflow paused at external gate.                        |
| `session.redacted`               | PII and documents permanently deleted.                   |
| `session.retention-expired`      | Retention window elapsed; data auto-redacted.            |
| `session.link_generated`         | A shareable verification link was created.               |
| `session.updated`                | Session external_ref or metadata updated.                |
| `session.submitted`              | Session submitted for processing after document capture. |
| `session.resumed`                | Session token regenerated via resume endpoint.           |
| `session.requires-second-review` | Session requires a second reviewer (dual review).        |
| `session.resubmission-required`  | Image quality insufficient; user must re-submit.         |

### Screening events

| Event                           | Description                                |
| ------------------------------- | ------------------------------------------ |
| `screening.completed`           | A screening check finished.                |
| `screening.monitoring.new_hits` | Continuous monitoring found new matches.   |
| `screening.monitoring.clear`    | Monitoring run completed with no new hits. |
| `check.completed`               | A standalone check completed.              |
| `check.matched`                 | A standalone check found matches.          |
| `check.monitoring.new_hits`     | Check monitoring found new hits.           |
| `check.monitoring.clear`        | Check monitoring found no new hits.        |

### Identity events

| Event                           | Description                        |
| ------------------------------- | ---------------------------------- |
| `identity.created`              | New identity record created.       |
| `identity.updated`              | Identity data updated.             |
| `identity.flagged`              | Identity flagged for review.       |
| `identity.archived`             | Identity archived.                 |
| `identity.restored`             | Identity restored from archive.    |
| `identity.merged`               | Two identity records merged.       |
| `identity.redacted`             | Identity PII permanently deleted.  |
| `identity.tag-added`            | Tag added to identity.             |
| `identity.tag-removed`          | Tag removed from identity.         |
| `identity.relationship.created` | Identity relationship established. |
| `identity.relationship.deleted` | Identity relationship removed.     |

### Consortium events

| Event                    | Description                                                                                 |
| ------------------------ | ------------------------------------------------------------------------------------------- |
| `consortium.match_found` | Cross-org face match detected via the Verifa Network. Both involved orgs receive the event. |

### Case events

| Event            | Description                          |
| ---------------- | ------------------------------------ |
| `case.created`   | Review case created.                 |
| `case.claimed`   | Case claimed by a reviewer.          |
| `case.approved`  | Case approved (final decision).      |
| `case.rejected`  | Case rejected (final decision).      |
| `case.escalated` | Case escalated to a higher reviewer. |

### Document events

| Event                 | Description                        |
| --------------------- | ---------------------------------- |
| `document.uploaded`   | Document file uploaded.            |
| `document.classified` | Document type detected.            |
| `document.extracted`  | Data extracted from document.      |
| `document.verified`   | Document verification completed.   |
| `document.compared`   | Document compared against another. |
| `document.redacted`   | Document permanently deleted.      |

### Quality assurance events

| Event                       | Description                   |
| --------------------------- | ----------------------------- |
| `smart_qa.finding.created`  | QA finding generated.         |
| `smart_qa.finding.critical` | Critical QA finding detected. |

## Event filtering

Subscribe to specific events or use `"*"` to receive all events:

```bash
curl -X PATCH https://api.withverifa.com/api/v1/webhooks/endpoints/webhook_abc123 \
  -H "X-API-Key: vk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled_events": ["session.approved", "session.declined", "screening.monitoring.new_hits"]
  }'
```

## Payload format

All webhook payloads use the same **envelope**: `event`, `created_at`, and
`idempotency_key` sit at the top level, and every event-specific field is
nested under `data`.

```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
  }
}
```

Route on the top-level `event`, then read resource fields from `data` (e.g.
`body.data.session_id`). The `data` contents vary by event type — session
events include `session_id`, `external_ref`, and `status`; identity events
include `identity_id` and the changed fields. See
[Handling webhook events](/tutorials/handling-webhook-events) for the full
per-event field reference.

## Signature verification

Every webhook 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-encoded HMAC-SHA256 of `f"{t}.{raw_request_body}"`, signed with
  your endpoint's `whsec_*` secret

**Always verify signatures** to prevent spoofed events. Use the **raw request
body** bytes (not parsed JSON) for verification, use a constant-time comparison
function for the `v1` value, and reject deliveries whose `t=` is more than
5 minutes from your current clock to prevent replay attacks. See the
[Tutorial: Receiving Webhooks & Validating Signatures](/tutorials/receiving-webhooks-validating-signatures)
for the full reference recipe and language-specific examples.

> **Note**
>
> The verification helper at `src/core/security.py:verify_webhook_signature`
> also accepts the legacy bare-hex HMAC over the raw body for in-flight
> deliveries during the deprecation window. New outbound deliveries always use
> the timestamped `t=,v1=` format, so build new receivers against that format.

### Replay protection

Receivers should layer the following defenses:

1. **Verify the signature** using the `t=,v1=` recipe above — reject anything
   that doesn't validate.
2. **Reject stale timestamps.** If `abs(now - t) > 300`, drop the delivery
   even if the HMAC checks out. This is the same 5-minute window enforced
   by every Verifa client.
3. **Dedupe on `idempotency_key`.** Verifa retries failed deliveries, and the
   same event may arrive more than once with the same `idempotency_key` in
   the payload body. Maintain a short-lived cache (Redis, DB row) keyed on
   `idempotency_key` to skip duplicates rather than hashing payloads.

## Retry behavior

Verifa waits up to **15 seconds** for your endpoint to respond. A delivery is
attempted **5 times in total** when the failure looks transient — a `5xx`
response, a timeout, a connection error, or a `408`, `425`, or `429`:

| Attempt | Delay after the previous attempt |
| ------- | -------------------------------- |
| 2nd     | \~30 seconds                     |
| 3rd     | \~2 minutes                      |
| 4th     | \~8 minutes                      |
| 5th     | \~32 minutes                     |

Each delay is jittered by ±25% so a recovering endpoint is not hit by every
queued delivery in the same second. After the 5th failed attempt the delivery
is marked `failed`; you can redeliver it from the dashboard or via the API.

**Other `4xx` responses are not retried.** A `401`, `403`, `404`, `410`, or
`422` means your endpoint received the request and rejected it as sent —
re-sending the identical request would get the identical answer. Those
deliveries are marked `failed` on the first attempt (and count toward the
circuit breaker below), so fix the endpoint and redeliver.

### Ordering and duplicates

Deliveries are **not ordered**. Events for the same session (for example
`session.completed` and `identity.updated`) are separate deliveries that can
arrive in either order, and a retried delivery can arrive after a newer event.
Use the `created_at` timestamp in the envelope to order events, and treat the
`idempotency_key` as the unit of deduplication: Verifa never sends two
different events with the same key, and the same key may reach you more than
once only if your endpoint failed to acknowledge it.

### Circuit breaker

If an endpoint accumulates 10 consecutive failed deliveries (all retries
exhausted), Verifa automatically disables the endpoint and sends an email
notification to your organization's admins. Re-enable it in the dashboard or via
API after fixing the issue.

## Troubleshooting delivery failures

### Deliveries are blocked by a firewall, WAF, or bot protection

This is the single most common reason a brand-new webhook endpoint never
receives events. Verifa delivers webhooks as **server-to-server `POST` requests** —
there is no browser, no JavaScript engine, and no human behind them. Bot-protection
products are tuned to flag exactly that traffic shape, so they often challenge or
block the request before it ever reaches your application.

The tell-tale sign is a delivery that fails with **HTTP `403`** and a response
body containing an interstitial challenge page (for example, Cloudflare's
`"Just a moment..."` page) rather than your own API's response. Check the
delivery's error in **Developers → Webhooks → delivery history**.

> **Note**
>
> A `curl` from your laptop or a browser test will usually succeed even when
> production deliveries fail — you look "human" enough to pass the challenge, or
> you solve it interactively. Because of this, the problem is typically invisible
> until your first real delivery.

Common culprits and fixes:

* **Cloudflare** — Bot Fight Mode, Super Bot Fight Mode, Managed Challenge, or
  Browser Integrity Check. Add a **WAF custom rule** that *skips* these for your
  webhook path (e.g. `URI Path equals /webhooks/verifa`), or set a
  configuration-rule exception for that route.
* **AWS WAF / Akamai / Imperva / Cloudfront** — add an allow rule for the webhook
  path, or for requests carrying the `X-Verifa-Signature` header.
* **Self-managed firewalls / IP allowlists** — allow inbound traffic to your
  webhook path from Verifa's egress.

You can scope the exception narrowly by matching on either of these, both
present on every delivery:

* the **`X-Verifa-Signature`** header — only HMAC-signed Verifa traffic bypasses
  the challenge; or
* our **`User-Agent`**, which always starts with `Verifa-Webhooks/`
  (e.g. `Verifa-Webhooks/1.0 (+https://docs.withverifa.com/webhooks)`).

For example, in Cloudflare a custom rule of *Skip → when `User-Agent` starts
with `Verifa-Webhooks/`* is enough to let deliveries through.

> **Warning**
>
> Allowlisting your webhook path past a WAF does **not** weaken your security,
> *provided you verify the signature*. Every delivery is HMAC-signed with your
> endpoint's `whsec_*` secret, so your handler can still reject any request that
> isn't genuinely from Verifa. See [Signature verification](#signature-verification).
> Never process unsigned or unverified requests, regardless of where they came from.

### Other things to check

* **TLS errors** — confirm your endpoint serves a valid, non-expired certificate
  for the exact hostname in the URL, and that it presents the full chain. Verifa
  validates certificates and will not deliver to a host that fails the handshake.
* **Timeouts** — your handler must return a response within 30 seconds. Return
  `200` immediately and process asynchronously (see [Best practices](#best-practices)).
* **Wrong status code** — only `2xx` counts as success. A `3xx` redirect is
  treated as a failure; point the endpoint URL directly at its final location.
* **Reachability** — the URL must be publicly resolvable. `localhost`, private
  IP ranges, and internal-only hostnames are rejected. Use a tunnel (e.g. ngrok)
  for local development.

## Managing endpoints

### Rotate the signing secret

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

Returns the new `whsec_*` secret in the response body. This is the only other
time the secret is shown — store it immediately. The previous secret is
invalidated as soon as rotation completes, so deploy the new value to your
server before or immediately after rotating. Rotating one endpoint never
affects another endpoint's secret.

### Clone an endpoint

Duplicate an endpoint with a new secret (useful for migration):

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

### View delivery history

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

### Retry a failed delivery

```bash
curl -X POST https://api.withverifa.com/api/v1/webhooks/deliveries/webhookdlv_abc123/retry \
  -H "X-API-Key: vk_live_your_key_here"
```

### Key inflection

Control the casing of payload keys per endpoint:

```bash
curl -X PATCH https://api.withverifa.com/api/v1/webhooks/endpoints/webhook_abc123 \
  -H "X-API-Key: vk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "key_inflection": "camel"
  }'
```

Options: `snake` (default), `camel`, `kebab`.

## Best practices

* **Respond quickly.** Return `200` immediately and process the event
  asynchronously. If your handler takes too long, the delivery may time out and
  trigger unnecessary retries.

* **Handle duplicates.** Use the `idempotency_key` field on the payload body
  to deduplicate — the same key arrives on every retry of the same delivery.
  In rare cases (network issues, retries), you may receive the same event
  more than once.

* **Verify signatures and timestamps.** Parse `t=` and `v1=` from the
  `X-Verifa-Signature` header, recompute the HMAC over `f"{t}.{raw_body}"`,
  and reject any delivery whose `t=` is more than 5 minutes from now. Never
  process unverified events.

* **Exempt from CSRF.** If your framework has CSRF protection, exempt your
  webhook endpoint path.

* **Use specific events.** Subscribe only to the events you need rather than
  `"*"`. This reduces noise and makes your handler simpler.

* **Monitor delivery health.** Check the delivery history in the dashboard
  regularly. If you see frequent failures, investigate timeouts or errors on
  your endpoint.

## Related

* [Webhooks](/api-details/webhooks) — Quick reference for event types and payload
  format
* [Sessions](/core-concepts/sessions) — Session events and lifecycle
* [Screening & Reports](/core-concepts/screening-reports) — Screening event details
* [Errors](/api-details/errors) — Error codes returned by webhook API endpoints