Tutorial: Receiving Webhooks & Validating Signatures

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. This header contains a hex-encoded HMAC-SHA256 hash computed from:

  • Key: Your endpoint’s signing secret (the whsec_... value)
  • Message: The raw HTTP request body (the exact bytes Verifa sent)

To verify, you recompute the HMAC on your side and compare it to the header value using a constant-time comparison function. This prevents both spoofed events and timing attacks.

HMAC-SHA256(secret, raw_request_body) == X-Verifa-Signature header

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.

Verify your implementation

Use this test vector to confirm your HMAC logic is correct before deploying. If your code produces the expected signature below, it’s working.

FieldValue
Secretwhsec_test_secret_do_not_use_in_production
Body{"event":"session.approved","data":{"session_id":"sess_test123"}}
Expected signatureb53570a554c5ef3c62270528a7513b01aaf300c0c68dd70bb7fbf8cb55381ed4
$# Verify from your terminal:
$echo -n '{"event":"session.approved","data":{"session_id":"sess_test123"}}' \
> | openssl dgst -sha256 -hmac "whsec_test_secret_do_not_use_in_production"

If you get b53570a554c5ef3c62...381ed4, your environment is set up correctly.

Build the handler

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;
6
7// IMPORTANT: Use express.raw() to get the raw body bytes for signature
8// verification. express.json() parses the body, which changes the bytes.
9app.post(
10 "/webhooks/verifa",
11 express.raw({ type: "application/json" }),
12 (req, res) => {
13 const signature = req.headers["x-verifa-signature"];
14
15 if (!signature) {
16 console.error("Missing X-Verifa-Signature header");
17 return res.status(401).send("Missing signature");
18 }
19
20 // Compute the expected signature from the raw body
21 const expected = crypto
22 .createHmac("sha256", WEBHOOK_SECRET)
23 .update(req.body)
24 .digest("hex");
25
26 // Use timingSafeEqual to prevent timing attacks
27 const isValid = crypto.timingSafeEqual(
28 Buffer.from(signature, "utf8"),
29 Buffer.from(expected, "utf8")
30 );
31
32 if (!isValid) {
33 console.error("Invalid webhook signature");
34 return res.status(401).send("Invalid signature");
35 }
36
37 // Signature verified — parse and process the event
38 const event = JSON.parse(req.body);
39 console.log(`Verified event: ${event.event}`);
40 console.log(`Session: ${event.session_id}`);
41 console.log(`Status: ${event.status}`);
42
43 // Respond immediately with 200
44 res.status(200).send("OK");
45
46 // Process asynchronously (e.g., update your database)
47 handleEvent(event);
48 }
49);
50
51function handleEvent(event) {
52 switch (event.event) {
53 case "session.approved":
54 console.log(`Session ${event.session_id} approved — activate user`);
55 break;
56 case "session.declined":
57 console.log(`Session ${event.session_id} declined — notify user`);
58 break;
59 case "session.requires-review":
60 console.log(`Session ${event.session_id} needs review`);
61 break;
62 default:
63 console.log(`Unhandled event: ${event.event}`);
64 }
65}
66
67app.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:

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.
Using simple string comparisonVulnerable to timing attacks that can leak the expected signature.Use crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), or hmac.Equal (Go).
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 secretMultiple endpoints have different secrets; 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 reading the raw request body for verification
  • Using constant-time comparison for the signature check
  • 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