For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
  • Getting Started
    • Introduction
    • How Verifa Works
    • Quickstart
    • Choosing an Integration Method
  • Use Cases
    • KYC Onboarding
    • Age Verification
    • AML Compliance
    • Fraud Prevention
    • Marketplace Trust & Safety
  • Core Concepts
    • Overview
    • Sessions
    • Verifications & Checks
    • Workflows
    • Identities
    • Cases
    • Screening & Reports
    • Lists
  • Integration Guides
    • Overview
    • JavaScript SDK
    • Web Capture Flow
    • API-Only Integration
    • Mobile SDK
    • Webhooks Guide
    • MCP Server
    • Migrating from Persona
  • API Details
    • Overview
    • Authentication
    • Pagination
    • Rate Limiting
    • Versioning
    • Errors
    • Webhooks
    • Idempotency
    • Key Inflection
    • Data Access
    • Data Retention
  • Tutorials
    • Creating Your First Verification Session
    • Creating a Workflow
    • Receiving Webhooks & Validating Signatures
    • Handling Webhook Events
    • Custom Document Types & AI Extraction
  • Best Practices
    • Testing
    • Preventing Duplicates
    • Fraud Signals
    • Changelog
  • API Reference
On this page
  • What you’ll learn
  • Prerequisites
  • Step 1: Expose your local server
  • Step 2: Create a webhook endpoint
  • Option A: Using the dashboard
  • Option B: Using the API
  • Store the secret
  • Step 3: Build your webhook receiver
  • How signature verification works
  • Reference recipe
  • Build the handler
  • Step 4: Test your endpoint
  • From the dashboard
  • From the API
  • Troubleshooting
  • Step 5: Trigger a real event
  • Create a session
  • Complete the capture flow
  • Step 6: Handle retries and duplicates
  • Retry schedule
  • Deduplication
  • Step 7: Rotate your signing secret
  • From the dashboard
  • From the API
  • Step 8: Check delivery history
  • From the dashboard
  • From the API
  • Common mistakes
  • Production checklist
  • Next steps
Tutorials

Tutorial: Receiving Webhooks & Validating Signatures

Was this page helpful?
Previous

Tutorial: Handling Webhook Events

Next
Built with

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)
  • 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 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):

$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

Click + Add Endpoint in the top right corner.

Webhook list with Add Endpoint button

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

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

Webhook Created modal with signing secret revealed

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

$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"
> }'
1{
2 "id": "webhook_abc123",
3 "url": "https://a1b2c3.ngrok.io/webhooks/verifa",
4 "secret": "whsec_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
5 "label": "Tutorial webhook",
6 "description": "Testing webhook signature verification",
7 "environment": "sandbox",
8 "enabled": true,
9 "enabled_events": ["session.approved", "session.declined", "session.requires-review"],
10 "api_version": null,
11 "key_inflection": null,
12 "event_filter_conditions": {},
13 "disabled_reason": null,
14 "created_at": "2026-02-01T12:00:00Z",
15 "updated_at": "2026-02-01T12:00:00Z"
16}

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:

$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

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.

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:

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

Build the handler

Node.js (Express)
Python (FastAPI)

Create a file called server.js:

1const express = require("express");
2const crypto = require("crypto");
3
4const app = express();
5const WEBHOOK_SECRET = process.env.VERIFA_WEBHOOK_SECRET;
6const TOLERANCE_SECONDS = 300; // 5-minute replay window
7
8function parseSignatureHeader(header) {
9 // Header is "t=<unix_ts>,v1=<hex_hmac>"
10 const parts = {};
11 for (const piece of (header || "").split(",")) {
12 const idx = piece.indexOf("=");
13 if (idx > 0) parts[piece.slice(0, idx)] = piece.slice(idx + 1);
14 }
15 return parts;
16}
17
18function verifySignature(rawBody, header, secret) {
19 const parts = parseSignatureHeader(header);
20 const ts = parseInt(parts.t || "0", 10);
21 const v1 = parts.v1 || "";
22 if (!ts || !v1) return false;
23
24 // Reject deliveries outside the tolerance window (replay protection)
25 const now = Math.floor(Date.now() / 1000);
26 if (Math.abs(now - ts) > TOLERANCE_SECONDS) return false;
27
28 // HMAC covers "<timestamp>.<raw_body>"
29 const signedPayload = Buffer.concat([
30 Buffer.from(`${ts}.`, "utf8"),
31 rawBody,
32 ]);
33 const expected = crypto
34 .createHmac("sha256", secret)
35 .update(signedPayload)
36 .digest("hex");
37
38 const expectedBuf = Buffer.from(expected, "utf8");
39 const actualBuf = Buffer.from(v1, "utf8");
40 if (expectedBuf.length !== actualBuf.length) return false;
41 return crypto.timingSafeEqual(expectedBuf, actualBuf);
42}
43
44// IMPORTANT: Use express.raw() to get the raw body bytes for signature
45// verification. express.json() parses the body, which changes the bytes.
46app.post(
47 "/webhooks/verifa",
48 express.raw({ type: "application/json" }),
49 (req, res) => {
50 const signature = req.headers["x-verifa-signature"];
51
52 if (!signature) {
53 console.error("Missing X-Verifa-Signature header");
54 return res.status(401).send("Missing signature");
55 }
56
57 if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
58 console.error("Invalid or expired webhook signature");
59 return res.status(401).send("Invalid signature");
60 }
61
62 // Signature verified — parse and process the event
63 const event = JSON.parse(req.body);
64 console.log(`Verified event: ${event.event}`);
65 console.log(`Session: ${event.session_id}`);
66 console.log(`Status: ${event.status}`);
67
68 // Respond immediately with 200
69 res.status(200).send("OK");
70
71 // Process asynchronously (e.g., update your database)
72 handleEvent(event);
73 }
74);
75
76function handleEvent(event) {
77 switch (event.event) {
78 case "session.approved":
79 console.log(`Session ${event.session_id} approved — activate user`);
80 break;
81 case "session.declined":
82 console.log(`Session ${event.session_id} declined — notify user`);
83 break;
84 case "session.requires-review":
85 console.log(`Session ${event.session_id} needs review`);
86 break;
87 default:
88 console.log(`Unhandled event: ${event.event}`);
89 }
90}
91
92app.listen(3000, () => console.log("Webhook receiver running on port 3000"));

Run it:

$npm install express
$VERIFA_WEBHOOK_SECRET="whsec_..." node server.js

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

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

From the API

$curl -X POST https://api.withverifa.com/api/v1/webhooks/endpoints/webhook_abc123/test \
> -H "X-API-Key: vk_sandbox_your_key_here"
1{
2 "success": true,
3 "http_status": 200,
4 "url": "https://a1b2c3.ngrok.io/webhooks/verifa"
5}

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

1{
2 "event": "verification.test",
3 "message": "This is a test webhook from Verifa"
4}

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

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

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

AttemptDelay
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
Python
Go
Ruby
PHP
Java
C#
1const processed = new Set(); // Use Redis or a database in production
2
3function handleEvent(event) {
4 const key = event.idempotency_key;
5 if (processed.has(key)) {
6 console.log(`Duplicate skipped: ${key}`);
7 return;
8 }
9 processed.add(key);
10
11 // Process the event...
12}

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

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

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

From the API

$curl -X POST https://api.withverifa.com/api/v1/webhooks/endpoints/webhook_abc123/rotate-secret \
> -H "X-API-Key: vk_sandbox_your_key_here"
1{
2 "id": "webhook_abc123",
3 "secret": "whsec_new_secret_value_here..."
4}

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.

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

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

From the API

$curl https://api.withverifa.com/api/v1/webhooks/endpoints/webhook_abc123/deliveries \
> -H "X-API-Key: vk_sandbox_your_key_here"
1{
2 "data": [
3 {
4 "id": "webhookdlv_def456",
5 "event": "session.approved",
6 "url": "https://a1b2c3.ngrok.io/webhooks/verifa",
7 "status": "delivered",
8 "http_status": 200,
9 "attempts": 1,
10 "max_attempts": 5,
11 "last_error": null,
12 "created_at": "2026-02-01T12:05:00Z"
13 }
14 ]
15}

To retry a failed delivery:

$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

MistakeWhat goes wrongFix
Parsing body before verifyingJSON.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 prefixThe 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 checkAn 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 comparisonVulnerable to timing attacks that can leak the expected signature.Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python).
Slow handler blocking responseVerifa times out after 15 seconds, triggering unnecessary retries.Return 200 immediately and process the event asynchronously.
Not handling duplicatesSame event processed multiple times, causing duplicate side effects.Deduplicate using the idempotency_key included in every payload.
Hardcoding the secretSecret ends up in source control.Use environment variables or a secrets manager.
Middleware modifying the bodyExpress 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 secretEach 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 — Full event catalog, event filtering, attribute blocklists, and key inflection
  • Webhooks API Reference — Payload format and retry behavior
  • Testing — Sandbox mode and test utilities
  • Preventing Duplicates — Advanced deduplication strategies