Skip to main content

Examples

Complete, production-ready webhook handler implementations. Each example covers the full flow: signature verification, idempotency, async processing, error handling, and observability.

Pick the stack that matches your backend.

Node.js / Express + Postgres

A typical setup: Express server, Postgres for state, BullMQ for async work.

import express from 'express';
import crypto from 'node:crypto';
import { Pool } from 'pg';
import { Queue } from 'bullmq';

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const verificationQueue = new Queue('verifications', {
connection: { host: process.env.REDIS_HOST },
});

const VERIDIA_SECRET = process.env.VERIDIA_WEBHOOK_SECRET;

app.post(
'/webhooks/veridia',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sigHeader = req.header('Veridia-Signature') || '';
const eventType = req.header('Veridia-Event') || '';
const rawBody = req.body;

// 1. Verify signature
if (!verifyVeridiaSignature(sigHeader, rawBody, VERIDIA_SECRET)) {
console.warn('Invalid Veridia signature', { eventType });
return res.status(401).send('Invalid signature');
}

const payload = JSON.parse(rawBody.toString('utf8'));

// 2. Idempotency check
const dedupKey = `${payload.verificationId}:${payload.event}`;
const exists = await pool.query(
'SELECT 1 FROM webhook_log WHERE dedup_key = $1',
[dedupKey]
);

if (exists.rows.length > 0) {
// Already processed — acknowledge and skip
return res.status(200).send('ok');
}

// 3. Persist the event (audit trail)
await pool.query(
`INSERT INTO webhook_log (dedup_key, event_type, verification_id, payload, received_at)
VALUES ($1, $2, $3, $4, NOW())`,
[dedupKey, payload.event, payload.verificationId, payload]
);

// 4. Acknowledge BEFORE doing heavy work
res.status(200).send('ok');

// 5. Queue async processing (don't block the response)
await verificationQueue.add(payload.event, payload, {
jobId: dedupKey, // BullMQ also dedupes by jobId
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
}
);

function verifyVeridiaSignature(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;

if (!timestamp || !receivedSig) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(expectedSig, 'hex'),
Buffer.from(receivedSig, 'hex')
);
}

// Worker: process verifications async
const worker = new Worker('verifications', async (job) => {
const payload = job.data;

switch (payload.event) {
case 'verification.approved':
await handleApproved(payload);
break;
case 'verification.rejected':
await handleRejected(payload);
break;
case 'verification.review_required':
await handleReviewRequired(payload);
break;
}
}, { connection: { host: process.env.REDIS_HOST } });

async function handleApproved(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'verified', kyc_completed_at = $1
WHERE user_ref = $2`,
[payload.completedAt, payload.userRef]
);
await sendWelcomeEmail(payload.userRef);
}

async function handleRejected(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'rejected'
WHERE user_ref = $1`,
[payload.userRef]
);
await sendRejectionEmail(payload.userRef);
}

async function handleReviewRequired(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'pending_review'
WHERE user_ref = $1`,
[payload.userRef]
);
await notifyReviewers(payload);
}

app.listen(3000, () => console.log('Webhook server listening on :3000'));

Required schema:

CREATE TABLE webhook_log (
dedup_key VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
verification_id VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);

CREATE INDEX idx_webhook_log_verification ON webhook_log(verification_id);
CREATE INDEX idx_webhook_log_received_at ON webhook_log(received_at);

Python / FastAPI + SQLAlchemy + Celery

import hmac
import hashlib
import time
import os
import json

from fastapi import FastAPI, Request, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from .database import get_db
from .models import WebhookLog, User
from .tasks import process_verification

app = FastAPI()
VERIDIA_SECRET = os.environ["VERIDIA_WEBHOOK_SECRET"].encode()


def verify_signature(header: str, raw_body: bytes, secret: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp_str = parts.get("t")
received_sig = parts.get("v1")

if not timestamp_str or not received_sig:
return False

try:
timestamp = int(timestamp_str)
except ValueError:
return False

if abs(time.time() - timestamp) > 300:
return False

signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()

return hmac.compare_digest(expected_sig, received_sig)


@app.post("/webhooks/veridia")
async def veridia_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
sig_header = request.headers.get("veridia-signature", "")
raw_body = await request.body()

if not verify_signature(sig_header, raw_body, VERIDIA_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")

payload = json.loads(raw_body)
dedup_key = f"{payload['verificationId']}:{payload['event']}"

# Idempotency check
existing = await db.execute(
WebhookLog.__table__.select().where(WebhookLog.dedup_key == dedup_key)
)
if existing.scalar_one_or_none():
return {"ok": True}

# Persist
log_entry = WebhookLog(
dedup_key=dedup_key,
event_type=payload["event"],
verification_id=payload["verificationId"],
payload=payload,
)
db.add(log_entry)
await db.commit()

# Queue async processing via Celery
process_verification.delay(payload)

return {"ok": True}


# Celery task (in tasks.py)
from celery import Celery

celery = Celery("veridia", broker=os.environ["REDIS_URL"])


@celery.task(bind=True, max_retries=3, default_retry_delay=5)
def process_verification(self, payload: dict):
try:
if payload["event"] == "verification.approved":
handle_approved(payload)
elif payload["event"] == "verification.rejected":
handle_rejected(payload)
elif payload["event"] == "verification.review_required":
handle_review_required(payload)
except Exception as exc:
raise self.retry(exc=exc)


def handle_approved(payload: dict):
update_user_kyc(payload["userRef"], "verified", payload["completedAt"])
send_welcome_email(payload["userRef"])

PHP / Laravel

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Jobs\ProcessVeridiaWebhook;

class VeridiaWebhookController
{
public function handle(Request $request)
{
$sigHeader = $request->header('Veridia-Signature', '');
$rawBody = $request->getContent();
$secret = config('services.veridia.webhook_secret');

// 1. Verify signature
if (!$this->verifySignature($sigHeader, $rawBody, $secret)) {
Log::warning('Invalid Veridia signature');
return response()->json(['error' => 'Invalid signature'], 401);
}

$payload = json_decode($rawBody, true);
$dedupKey = $payload['verificationId'] . ':' . $payload['event'];

// 2. Idempotency check
$exists = DB::table('webhook_log')
->where('dedup_key', $dedupKey)
->exists();

if ($exists) {
return response()->json(['ok' => true]);
}

// 3. Persist
DB::table('webhook_log')->insert([
'dedup_key' => $dedupKey,
'event_type' => $payload['event'],
'verification_id' => $payload['verificationId'],
'payload' => json_encode($payload),
'received_at' => now(),
]);

// 4. Queue async (Laravel jobs)
ProcessVeridiaWebhook::dispatch($payload);

return response()->json(['ok' => true]);
}

private function verifySignature(string $header, string $rawBody, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $p) {
$kv = explode('=', $p, 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}

$timestamp = (int)($parts['t'] ?? 0);
$receivedSig = $parts['v1'] ?? '';

if ($timestamp === 0 || $receivedSig === '') {
return false;
}

if (abs(time() - $timestamp) > 300) {
return false;
}

$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);

return hash_equals($expectedSig, $receivedSig);
}
}


// app/Jobs/ProcessVeridiaWebhook.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\User;

class ProcessVeridiaWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public int $tries = 3;
public int $backoff = 5;

public function __construct(public array $payload) {}

public function handle(): void
{
match ($this->payload['event']) {
'verification.approved' => $this->handleApproved(),
'verification.rejected' => $this->handleRejected(),
'verification.review_required' => $this->handleReviewRequired(),
default => Log::warning('Unknown Veridia event', $this->payload),
};
}

private function handleApproved(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'verified',
'kyc_completed_at' => $this->payload['completedAt'],
]);
}

private function handleRejected(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'rejected',
]);
}

private function handleReviewRequired(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'pending_review',
]);
}
}

Routes (routes/api.php):

Route::post('/webhooks/veridia', [VeridiaWebhookController::class, 'handle']);

Important: disable Laravel's CSRF protection for the webhook route. Add webhooks/* to $except in app/Http/Middleware/VerifyCsrfToken.php.

Cloudflare Workers (serverless)

For a fully serverless setup using Cloudflare Workers + KV:

export default {
async fetch(request, env, ctx) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}

const sigHeader = request.headers.get('Veridia-Signature') || '';
const rawBody = await request.text();

// 1. Verify signature
if (!await verifySignature(sigHeader, rawBody, env.VERIDIA_WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}

const payload = JSON.parse(rawBody);
const dedupKey = `${payload.verificationId}:${payload.event}`;

// 2. Idempotency via KV
const existing = await env.WEBHOOK_LOG.get(dedupKey);
if (existing) {
return new Response('ok', { status: 200 });
}

// 3. Mark as received
await env.WEBHOOK_LOG.put(dedupKey, JSON.stringify(payload), {
expirationTtl: 86400 * 7, // 7 days
});

// 4. Process async via Queue (or just inline if quick)
ctx.waitUntil(processVerification(payload, env));

return new Response('ok', { status: 200 });
},
};

async function verifySignature(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;

if (!timestamp || !receivedSig) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sigBytes = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(signedPayload)
);
const expectedSig = Array.from(new Uint8Array(sigBytes))
.map(b => b.toString(16).padStart(2, '0'))
.join('');

// Constant-time comparison
if (expectedSig.length !== receivedSig.length) return false;
let diff = 0;
for (let i = 0; i < expectedSig.length; i++) {
diff |= expectedSig.charCodeAt(i) ^ receivedSig.charCodeAt(i);
}
return diff === 0;
}

Observability checklist

For production deployments, log these fields on every webhook:

FieldWhy
verificationIdTrace it across your system
eventFilter by event type
tenantIdMulti-tenant routing
signature_validCatch unauthorized attempts
dedup_hitSpot retry loops
processing_duration_msSLO tracking
error_classTriage failures

A good Datadog / Honeycomb / Loki query: webhook_received{event="verification.review_required"} | rate() shows your review queue volume in real time.

Testing locally

For local dev, replay a captured webhook with curl:

#!/bin/bash
# capture a real webhook from your dashboard "Webhooks > Deliveries",
# then save the raw body and signature header
SECRET="whsec_your_test_secret"
BODY=$(cat captured-body.json)
TS=$(date +%s)
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:3000/webhooks/veridia \
-H "Content-Type: application/json" \
-H "Veridia-Signature: t=${TS},v1=${SIG}" \
-H "Veridia-Event: verification.approved" \
-d "$BODY"

What's next