> 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: Receiving Webhooks & Validating Signatures

> Build a working webhook receiver from scratch, verify HMAC-SHA256 signatures, and test it end-to-end using the Verifa API.

In this tutorial, you'll build a working webhook receiver that listens for Verifa
events, validates their HMAC-SHA256 signatures, and processes them safely. By the
end, you'll have a production-ready webhook handler you can adapt to your stack.

Without signature verification, anyone who discovers your webhook URL can send
fake events to your server — potentially granting unauthorized access to users,
triggering incorrect business logic, or corrupting your data. Verifying the
HMAC-SHA256 signature on every request ensures the event genuinely came from
Verifa and hasn't been tampered with in transit.

## What you'll learn

* Creating a webhook endpoint via the Verifa API
* Building a receiver that verifies signatures
* Testing your endpoint with Verifa's built-in test tool
* Handling retries, duplicates, and secret rotation

## Prerequisites

* A Verifa account with API access ([sign up](https://app.withverifa.com))
* An API key with `webhooks:read` and `webhooks:write` scopes
* One of: Node.js 18+, Python 3.10+, Go 1.21+, or any language with HMAC-SHA256 support
* A publicly accessible URL (use [ngrok](https://ngrok.com) for local development)

## Step 1: Expose your local server

During development, your webhook receiver needs a public URL that Verifa can
reach. Start an ngrok tunnel (or any similar tool):

```bash
ngrok http 3000
```

Copy the forwarding URL (e.g., `https://a1b2c3.ngrok.io`). You'll use this when
creating your webhook endpoint.

## Step 2: Create a webhook endpoint

You can create a webhook endpoint from the dashboard or via the API.

### Option A: Using the dashboard

From the dashboard, expand **Webhooks** under the **Developers** section in the
left sidebar, then click **Webhooks**.

![Navigate to Webhooks in the sidebar](/_fern-img/a94b6e1be0c5279c466025ec27f240b8e9e76b3c1fe693165b0625e72501ca90.webp)

Click **+ Add Endpoint** in the top right corner.

![Webhook list with Add Endpoint button](/_fern-img/206466a41f9214f69efcd13e00354dcf00140df2e2913c725d3053da7a8c956f.webp)

Fill in your endpoint details in the **Add Webhook Endpoint** modal:

* **URL**: Paste your ngrok forwarding URL (e.g., `https://a1b2c3.ngrok.io/webhooks/verifa`)
* **Label**: `Tutorial Webhook`
* **Events**: Check `session.approved`, `session.declined`, and `session.requires-review`

You can also configure **API Version**, **Key Inflection**, and **Check Type
Filter** — leave them as defaults for now.

![New webhook endpoint form with URL, label, and events configured](/_fern-img/1e320f3ae21c3f5acf6dd69a8b6d7c4eb79a63ba7090e0bb05b7defc43ee9edc.webp)

Click **Add Endpoint**. A **Webhook Created** modal appears with your signing
secret and a Copy button.

![Webhook Created modal with signing secret revealed](/_fern-img/d7088296ab8811a206efebd16324e5d00c67d2be5da86725afb4b1e53a6a2fdb.webp)

> **Warning**
>
> Copy the signing secret now using the **Copy** button. The modal warns: "Save
> this signing secret now. It will not be shown again." If you lose it, you'll
> need to rotate to get a new one.

### Option B: Using the API

```bash
curl -X POST https://api.withverifa.com/api/v1/webhooks/endpoints \
  -H "X-API-Key: vk_sandbox_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://a1b2c3.ngrok.io/webhooks/verifa",
    "enabled_events": ["session.approved", "session.declined", "session.requires-review"],
    "label": "Tutorial webhook",
    "description": "Testing webhook signature verification"
  }'
```

```json
{
  "id": "webhook_abc123",
  "url": "https://a1b2c3.ngrok.io/webhooks/verifa",
  "secret": "whsec_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
  "label": "Tutorial webhook",
  "description": "Testing webhook signature verification",
  "environment": "sandbox",
  "enabled": true,
  "enabled_events": ["session.approved", "session.declined", "session.requires-review"],
  "api_version": null,
  "key_inflection": null,
  "event_filter_conditions": {},
  "disabled_reason": null,
  "created_at": "2026-02-01T12:00:00Z",
  "updated_at": "2026-02-01T12:00:00Z"
}
```

> **Warning**
>
> Save the `secret` value immediately. It is only returned when the endpoint is
> created and when you rotate the secret. You cannot retrieve it later. (In
> sandbox mode, the secret remains visible on GET requests.)

### Store the secret

Whichever method you used, store the secret as an environment variable:

```bash
export VERIFA_WEBHOOK_SECRET="whsec_7f3a9b2c1d..."
```

## Step 3: Build your webhook receiver

Choose your language and build a handler that receives the raw request body,
verifies the HMAC-SHA256 signature, and processes the event.

### How signature verification works

Every webhook Verifa sends includes an `X-Verifa-Signature` header in the
Stripe-style `t=<unix_ts>,v1=<hex_hmac>` format. The HMAC-SHA256 covers the
timestamp and body joined by a literal `.`:

* **Key**: Your endpoint's signing secret (the `whsec_...` value)
* **Message**: `f"{timestamp}.{raw_request_body}"` — the Unix timestamp from
  `t=`, a literal period, then the raw HTTP request body bytes Verifa sent

To verify, you parse `t=` and `v1=` out of the header, recompute the HMAC over
`{t}.{body}`, and compare it to `v1` using a constant-time comparison. You then
reject any delivery whose `t=` value is more than five minutes away from your
current clock — this is the replay-protection window enforced by every Verifa
client (`tolerance_seconds=300`).

```
HMAC-SHA256(secret, "<t>." + raw_request_body) == v1
abs(now - t) <= 300 seconds
```

> **Note**
>
> For backwards compatibility during the deprecation window, the verification
> helper in `src/core/security.py:verify_webhook_signature` also accepts the
> legacy bare-hex HMAC over the raw body. New outbound deliveries always use the
> timestamped format, so you should implement the new format in your receiver
> and only fall back to the legacy form if you have in-flight deliveries to
> drain.

> **Warning**
>
> You must use the **raw request body bytes** for verification, not a
> parsed-and-re-serialized JSON object. Parsing and re-serializing can change key
> order, spacing, or encoding, which will produce a different hash.

### Reference recipe

The recipe receivers should follow:

```python
import hmac, hashlib, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    ts = int(parts.get("t", "0"))
    v1 = parts.get("v1", "")
    if abs(int(time.time()) - ts) > tolerance:
        return False  # outside tolerance window
    expected = hmac.new(
        secret.encode(), f"{ts}.".encode() + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)
```

### Build the handler

#### Node.js (Express)

Create a file called `server.js`:

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

const app = express();
const WEBHOOK_SECRET = process.env.VERIFA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5-minute replay window

function parseSignatureHeader(header) {
  // Header is "t=<unix_ts>,v1=<hex_hmac>"
  const parts = {};
  for (const piece of (header || "").split(",")) {
    const idx = piece.indexOf("=");
    if (idx > 0) parts[piece.slice(0, idx)] = piece.slice(idx + 1);
  }
  return parts;
}

function verifySignature(rawBody, header, secret) {
  const parts = parseSignatureHeader(header);
  const ts = parseInt(parts.t || "0", 10);
  const v1 = parts.v1 || "";
  if (!ts || !v1) return false;

  // Reject deliveries outside the tolerance window (replay protection)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > TOLERANCE_SECONDS) return false;

  // HMAC covers "<timestamp>.<raw_body>"
  const signedPayload = Buffer.concat([
    Buffer.from(`${ts}.`, "utf8"),
    rawBody,
  ]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  const expectedBuf = Buffer.from(expected, "utf8");
  const actualBuf = Buffer.from(v1, "utf8");
  if (expectedBuf.length !== actualBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, actualBuf);
}

// IMPORTANT: Use express.raw() to get the raw body bytes for signature
// verification. express.json() parses the body, which changes the bytes.
app.post(
  "/webhooks/verifa",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-verifa-signature"];

    if (!signature) {
      console.error("Missing X-Verifa-Signature header");
      return res.status(401).send("Missing signature");
    }

    if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
      console.error("Invalid or expired webhook signature");
      return res.status(401).send("Invalid signature");
    }

    // Signature verified — parse and process the event
    const event = JSON.parse(req.body);
    console.log(`Verified event: ${event.event}`);
    console.log(`Session: ${event.session_id}`);
    console.log(`Status: ${event.status}`);

    // Respond immediately with 200
    res.status(200).send("OK");

    // Process asynchronously (e.g., update your database)
    handleEvent(event);
  }
);

function handleEvent(event) {
  switch (event.event) {
    case "session.approved":
      console.log(`Session ${event.session_id} approved — activate user`);
      break;
    case "session.declined":
      console.log(`Session ${event.session_id} declined — notify user`);
      break;
    case "session.requires-review":
      console.log(`Session ${event.session_id} needs review`);
      break;
    default:
      console.log(`Unhandled event: ${event.event}`);
  }
}

app.listen(3000, () => console.log("Webhook receiver running on port 3000"));
```

Run it:

```bash
npm install express
VERIFA_WEBHOOK_SECRET="whsec_..." node server.js
```

#### Python (FastAPI)

Create a file called `server.py`:

```python
import hashlib
import hmac
import os
import time

from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = os.environ["VERIFA_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300  # 5-minute replay window


def verify_signature(body: bytes, header: str, secret: str) -> bool:
    # Header is "t=<unix_ts>,v1=<hex_hmac>"
    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

    # Reject deliveries outside the tolerance window (replay protection)
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return False

    # HMAC covers "<timestamp>.<raw_body>"
    signed_payload = f"{ts}.".encode() + body
    expected = hmac.new(
        secret.encode(), signed_payload, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)


@app.post("/webhooks/verifa")
async def handle_webhook(request: Request):
    # Read the raw body bytes for signature verification
    body = await request.body()

    signature = request.headers.get("X-Verifa-Signature")
    if not signature:
        raise HTTPException(status_code=401, detail="Missing signature")

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

    # Signature verified — parse and process the event
    event = await request.json()
    event_type = event["event"]
    data = event.get("data", {})
    session_id = data.get("session_id")

    print(f"Verified event: {event_type} for session {session_id}")

    if event_type == "session.approved":
        # Activate the user in your system
        print(f"Session {session_id} approved — activate user")
    elif event_type == "session.declined":
        # Notify the user
        print(f"Session {session_id} declined — notify user")
    elif event_type == "session.requires-review":
        print(f"Session {session_id} needs manual review")

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

Run it:

```bash
pip install fastapi uvicorn
VERIFA_WEBHOOK_SECRET="whsec_..." uvicorn server:app --port 3000
```

## Step 4: Test your endpoint

With your server running and ngrok forwarding traffic, send a test event to
verify everything is wired up.

### From the dashboard

On the Webhooks list page, click the **...** menu on your endpoint's row and
select **Send test event**.

![Endpoint menu with Send test event option](/_fern-img/5c7802bcc640fd3634f92241c9442fd455e3448e3e2711ff5cf84e0c70839c64.webp)

A **Test Delivery Result** modal appears showing the outcome — a green
checkmark with "Delivery successful" and the HTTP status code, or an error if
your server rejected it.

![Test Delivery Result modal — Delivery successful, HTTP 200](/_fern-img/6b3dd28a713675332a6b776c55dd658de57d82df6f2379f1ad143cca9894c257.webp)

### From the API

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

```json
{
  "success": true,
  "http_status": 200,
  "url": "https://a1b2c3.ngrok.io/webhooks/verifa"
}
```

The test sends a `verification.test` event to your endpoint, signed with your
endpoint's secret:

```json
{
  "event": "verification.test",
  "created_at": "2026-02-01T12:05:00Z",
  "data": {
    "message": "This is a test webhook from Verifa"
  }
}
```

Any 2xx response from your server counts as success.

### Troubleshooting

If the test fails, check:

* Is ngrok running and forwarding to the correct port?
* Is your `VERIFA_WEBHOOK_SECRET` set correctly?
* Are you reading the **raw** body (not parsed JSON) for verification?

## Step 5: Trigger a real event

Create a verification session in sandbox mode to trigger a real webhook.

### Create a session

```bash
curl -X POST https://api.withverifa.com/api/v1/sessions \
  -H "X-API-Key: vk_sandbox_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "tutorial_user_123"
  }'
```

### Complete the capture flow

Open the `capture_url` from the response in your browser and walk through the
verification steps (document upload, selfie, etc.). In sandbox mode, sessions
move to `sandbox_pending` status after capture, and you'll be prompted to
**choose a verification outcome** (e.g., approve or decline).

Once you select an outcome, Verifa dispatches the corresponding webhook event
(e.g., `session.approved` or `session.declined`) to your endpoint.

> **Note**
>
> Sandbox sessions do not auto-complete. You must finish the capture flow and
> select an outcome to trigger webhook events.

## Step 6: Handle retries and duplicates

Verifa retries failed deliveries with exponential backoff. Your handler should
be prepared for this.

### Retry schedule

| 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 attempts, the delivery is marked as failed. If 10 consecutive
deliveries fail (across all events), the endpoint is automatically disabled and
your org admins receive an email notification.

### Deduplication

Every webhook payload includes an `idempotency_key` — a unique identifier for
that specific delivery (e.g. `idk_a1b2c3d4e5f6...`). If Verifa retries a
delivery, the same `idempotency_key` is sent again, so you can use it to detect
and skip duplicates:

#### Node.js

```javascript
const processed = new Set(); // Use Redis or a database in production

function handleEvent(event) {
  const key = event.idempotency_key;
  if (processed.has(key)) {
    console.log(`Duplicate skipped: ${key}`);
    return;
  }
  processed.add(key);

  // Process the event...
}
```

#### Python

```python
processed = set()  # Use Redis or a database in production

def handle_event(event: dict):
    key = event["idempotency_key"]
    if key in processed:
        print(f"Duplicate skipped: {key}")
        return
    processed.add(key)

    # Process the event...
```

#### Go

```go
var processed = make(map[string]bool) // Use Redis or a database in production
var mu sync.Mutex

func handleEvent(event map[string]interface{}) {
    key, _ := event["idempotency_key"].(string)

    mu.Lock()
    if processed[key] {
        mu.Unlock()
        log.Printf("Duplicate skipped: %s", key)
        return
    }
    processed[key] = true
    mu.Unlock()

    // Process the event...
}
```

#### Ruby

```ruby
processed = Set.new # Use Redis or a database in production

def handle_event(event, processed)
  key = event['idempotency_key']
  return puts("Duplicate skipped: #{key}") if processed.include?(key)

  processed.add(key)

  # Process the event...
end
```

#### PHP

```php
// Use Redis or a database in production
$cacheFile = sys_get_temp_dir() . '/webhook_processed.json';
$processed = file_exists($cacheFile)
    ? json_decode(file_get_contents($cacheFile), true) : [];

$key = $event['idempotency_key'];
if (in_array($key, $processed)) {
    error_log("Duplicate skipped: {$key}");
    return;
}
$processed[] = $key;
file_put_contents($cacheFile, json_encode($processed));

// Process the event...
```

#### Java

```java
// Use Redis or a database in production
private final Set<String> processed = ConcurrentHashMap.newKeySet();

private void handleEvent(Map<String, Object> event) {
    String key = (String) event.get("idempotency_key");

    if (!processed.add(key)) {
        log.info("Duplicate skipped: {}", key);
        return;
    }

    // Process the event...
}
```

#### C\#

```csharp
// Use Redis or a database in production
private static readonly HashSet<string> Processed = new();

private void HandleEvent(JsonDocument eventDoc)
{
    var key = eventDoc.RootElement
        .GetProperty("idempotency_key").GetString();

    lock (Processed)
    {
        if (!Processed.Add(key!))
        {
            Console.WriteLine($"Duplicate skipped: {key}");
            return;
        }
    }

    // Process the event...
}
```

## Step 7: Rotate your signing secret

If your webhook secret is compromised, rotate it immediately.

### From the dashboard

On the Webhooks list page, click the **...** menu on your endpoint's row and
select **Rotate secret**.

![Endpoint menu with Rotate secret option](/_fern-img/4a1aead25de878d3227076974257b1cf45ebf7dc39718cd0118d98836670312d.webp)

A **Rotate Signing Secret** modal warns that this will immediately invalidate
your current secret. Enter your account password and click **Confirm**.

![Rotate Signing Secret modal with password prompt](/_fern-img/187530663582676a7c58ca9a2ac446e8321b26a2ef1dd029c16170dac9cc0e74.webp)

A **Secret Rotated** modal appears with the new signing secret and a Copy
button. The modal warns: "Your old secret is now invalid. Update your server
before closing."

![Secret Rotated modal with new secret revealed](/_fern-img/fd36a7d6433d0eda992f28eeeb24a4ba4dd998fca2c6362763a5120bfb11efbd.webp)

### From the API

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

```json
{
  "id": "webhook_abc123",
  "secret": "whsec_new_secret_value_here..."
}
```

Update your environment variable with the new secret. The old secret stops
working immediately, so deploy the new secret to your server before or
immediately after rotation.

> **Tip**
>
> For zero-downtime rotation, clone the endpoint first (`POST .../clone`), update
> your server to accept signatures from either secret, rotate the original
> endpoint's secret, then delete the clone.

## Step 8: Check delivery history

Monitor your webhook deliveries to catch failures early.

### From the dashboard

Navigate to **Developers > Webhooks > Webhook Events** in the sidebar. This
page shows all deliveries across your endpoints, with filters for endpoint,
status, date range, and event type.

![Webhook Events page showing delivery history with filters](/_fern-img/937f3f58a6ca72cd692788e3bc11a199476909494a50a59b18e460aab460038f.webp)

Click the expand arrow on any delivery row to see the full payload, including
the `event` type and `idempotency_key`. If a delivery failed, a **Retry**
button appears on the expanded row.

![Expanded delivery showing the full webhook payload](/_fern-img/49f42cd399717109689d53fb75d4bbe13a42659b516db5f28f59a52baefb5448.webp)

### From the API

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

```json
{
  "data": [
    {
      "id": "webhookdlv_def456",
      "event": "session.approved",
      "url": "https://a1b2c3.ngrok.io/webhooks/verifa",
      "status": "delivered",
      "http_status": 200,
      "attempts": 1,
      "max_attempts": 5,
      "last_error": null,
      "created_at": "2026-02-01T12:05:00Z"
    }
  ]
}
```

To retry a failed delivery:

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

## Common mistakes

| Mistake                                       | What goes wrong                                                                                                                         | Fix                                                                                                       |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Parsing body before verifying                 | `JSON.parse()` or `request.json()` changes the bytes, so the HMAC won't match.                                                          | Always compute the signature from the raw body bytes first.                                               |
| Hashing the body without the timestamp prefix | The HMAC covers `f"{t}.{body}"`, not just the body — so leaving off the `t.` prefix produces a different digest and verification fails. | Always include the `t=` value followed by a literal `.` in front of the raw body when computing the HMAC. |
| Skipping the tolerance check                  | An attacker who captures one signed delivery can replay it forever.                                                                     | Reject any delivery whose `t=` timestamp is more than 5 minutes from your current clock.                  |
| Using simple string comparison                | Vulnerable to timing attacks that can leak the expected signature.                                                                      | Use `crypto.timingSafeEqual` (Node.js) or `hmac.compare_digest` (Python).                                 |
| Slow handler blocking response                | Verifa times out after 15 seconds, triggering unnecessary retries.                                                                      | Return `200` immediately and process the event asynchronously.                                            |
| Not handling duplicates                       | Same event processed multiple times, causing duplicate side effects.                                                                    | Deduplicate using the `idempotency_key` included in every payload.                                        |
| Hardcoding the secret                         | Secret ends up in source control.                                                                                                       | Use environment variables or a secrets manager.                                                           |
| Middleware modifying the body                 | Express `json()`, Django middleware, or a WAF can parse/re-encode the body before your handler runs.                                    | Ensure your webhook route receives the raw, unmodified bytes.                                             |
| Using the wrong secret                        | Each endpoint has its own `whsec_*` secret; using the wrong one silently fails.                                                         | Store secrets keyed by endpoint ID, not as a single global value.                                         |

## Production checklist

Before going live, make sure you've covered these:

* [ ] Signature verification is working (test with the `/test` endpoint)
* [ ] You're parsing both `t=` and `v1=` from the `X-Verifa-Signature` header
* [ ] You're reading the **raw** request body for verification
* [ ] HMAC input is `f"{t}.{body}"` (timestamp, literal `.`, then raw body bytes)
* [ ] Using constant-time comparison for the signature check
* [ ] You're rejecting deliveries with a `t=` outside the 5-minute tolerance window
* [ ] Returning `200` immediately and processing asynchronously
* [ ] Deduplication logic is in place (backed by a persistent store)
* [ ] Webhook secret is in an environment variable, not in code
* [ ] Your endpoint is HTTPS (Verifa rejects plain HTTP URLs)
* [ ] CSRF protection is disabled for the webhook route
* [ ] You're monitoring delivery health in the dashboard
* [ ] You've subscribed only to the events you need
* [ ] If using a reverse proxy, it forwards the `X-Verifa-Signature` header unmodified

## Next steps

* [Webhooks Guide](/integration-guides/webhooks-guide) — Full event catalog, event filtering,
  attribute blocklists, and key inflection
* [Webhooks API Reference](/api-details/webhooks) — Payload format and retry behavior
* [Testing](/best-practices/testing) — Sandbox mode and test utilities
* [Preventing Duplicates](/best-practices/preventing-duplicates) — Advanced deduplication
  strategies