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:
| Field | Why |
|---|---|
verificationId | Trace it across your system |
event | Filter by event type |
tenantId | Multi-tenant routing |
signature_valid | Catch unauthorized attempts |
dedup_hit | Spot retry loops |
processing_duration_ms | SLO tracking |
error_class | Triage 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
- Webhooks overview — back to the overview
- Signature verification — algorithm reference
- Event types — payload schemas