Skip to main content

Examples

Complete handler implementations: signature verification, idempotency, async processing, and observability. Pick the one matching your stack.

All four follow the same structure, and it is worth naming the parts before reading the code:

  1. Verify the signature over the raw bytes. Reject with 401 if it fails.
  2. Deduplicate on payload.id. Delivery is at least once.
  3. Persist, then acknowledge. The row you write is the acknowledgement; the work happens after.
  4. Route on payload.type. Not event.

These examples key user records on userRef, which is the value you passed to /v1/verify/init. It is null if you did not pass one — in that case store the verificationId → user mapping yourself at init time and look it up here. There is no other correlation field: metadata sent to /submit is not echoed in the event.

Node.js / Express + Postgres + BullMQ

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

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

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 rawBody = req.body;

// 1. Verify the signature over the raw bytes
if (!verifyVeridiaSignature(sigHeader, rawBody, VERIDIA_SECRET)) {
console.warn('Invalid Veridia signature', {
eventId: req.header('Veridia-Event-Id'),
});
return res.status(401).send('Invalid signature');
}

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

// 2. Idempotency. `id` is the evt_* value: stable across the six retry
// attempts of one event, distinct between events. A key built from
// verificationId would collapse the machine's review_required event
// and the reviewer's later approval into one, and the approval —
// arriving second — would be the one thrown away.
const eventId = payload.id;

// 3. Persist. ON CONFLICT makes the insert itself the dedup check, so two
// concurrent retries cannot both pass a separate SELECT.
const inserted = await pool.query(
`INSERT INTO webhook_log (event_id, event_type, verification_id, payload, received_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[eventId, payload.type, payload.verificationId, payload]
);

if (inserted.rowCount === 0) {
return res.status(200).send('ok'); // already seen
}

// 4. Hand the work off to durable storage BEFORE acknowledging.
// Order matters here and the tempting order is wrong. Answering 200
// first feels faster, but if the process dies in the gap the dedup row
// is already committed and Veridia has already been told "received" —
// so it never retries, and that verdict is gone with nothing logged.
// `jobId: eventId` makes the enqueue itself idempotent, so a retry that
// reaches this line twice still produces one job.
await verificationQueue.add(payload.type, payload, {
jobId: eventId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});

// 5. Now acknowledge. The heavy work happens in the worker, not here:
// Veridia's delivery timeout is 10 seconds.
res.status(200).send('ok');
}
);

function verifyVeridiaSignature(header, rawBody, secret) {
const parts = {};
for (const piece of header.split(',')) {
const idx = piece.indexOf('=');
if (idx > 0) parts[piece.slice(0, idx).trim()] = piece.slice(idx + 1).trim();
}

const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
if (Math.abs(Math.floor(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');

// Length check first: timingSafeEqual throws RangeError on unequal
// lengths, turning a forged short signature into a 500 instead of a 401.
const expectedBuf = Buffer.from(expectedSig, 'utf8');
const receivedBuf = Buffer.from(receivedSig, 'utf8');
if (expectedBuf.length !== receivedBuf.length) return false;

return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}

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

switch (payload.type) {
case 'verification.approved':
return handleApproved(payload);
case 'verification.rejected':
return handleRejected(payload);
case 'verification.review_required':
return handleReviewRequired(payload);
default:
// Log and succeed. Throwing here would retry a job that can never pass.
console.warn('Unknown Veridia event type', {
type: payload.type,
eventId: payload.id,
});
}
}, { connection });

async function handleApproved(payload) {
// createdAt is unix SECONDS. There is no completedAt in the payload.
await pool.query(
`UPDATE users SET kyc_status = 'verified', kyc_completed_at = to_timestamp($1)
WHERE user_ref = $2`,
[payload.createdAt, 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 sendGenericFailureEmail(payload.userRef); // never expose flags
}

async function handleReviewRequired(payload) {
await pool.query(
`UPDATE users SET kyc_status = 'pending_review' WHERE user_ref = $1`,
[payload.userRef]
);
await notifyReviewers({
verificationId: payload.verificationId,
flags: payload.flags,
// Levels are ok / warn / err. There is no 'critical'.
priority: payload.flags.some(f => f.level === 'err') ? 'high' : 'normal',
});
}

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

Schema:

CREATE TABLE webhook_log (
event_id VARCHAR(64) PRIMARY KEY, -- payload.id (evt_*)
event_type VARCHAR(64) NOT NULL, -- payload.type
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);

event_id is the primary key, and verification_id is only indexed. One verification legitimately produces several events — a review_required followed by the reviewer's approved — so making verification_id unique would reject the decision that matters.

The payload column now holds fieldsExtracted: full name, document number, date of birth. Whatever retention and access policy covers personal data at your company covers this table.

Python / FastAPI + SQLAlchemy + Celery

import hmac
import hashlib
import time
import os
import json

from fastapi import FastAPI, Request, HTTPException, Depends
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession

from .database import get_db
from .models import WebhookLog
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)

# .get(), not payload["..."]: a KeyError here becomes a 500, and the
# dispatcher would retry it six times before parking the event as failed.
event_id = payload.get("id")
event_type = payload.get("type")
if not event_id or not event_type:
# Malformed for us, but not something a retry can fix. 2xx and log.
return {"ok": True, "ignored": "missing id or type"}

# Idempotency: let the unique constraint decide, not a prior SELECT.
stmt = (
pg_insert(WebhookLog.__table__)
.values(
event_id=event_id,
event_type=event_type,
verification_id=payload["verificationId"],
payload=payload,
)
.on_conflict_do_nothing(index_elements=["event_id"])
.returning(WebhookLog.__table__.c.event_id)
)
result = await db.execute(stmt)
await db.commit()

if result.scalar_one_or_none() is None:
return {"ok": True} # duplicate delivery

process_verification.delay(payload)
return {"ok": True}
# tasks.py
import os
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:
event_type = payload["type"]

if event_type == "verification.approved":
handle_approved(payload)
elif event_type == "verification.rejected":
handle_rejected(payload)
elif event_type == "verification.review_required":
handle_review_required(payload)
else:
log.warning("unknown_veridia_event", type=event_type, id=payload["id"])
except Exception as exc:
raise self.retry(exc=exc)


def handle_approved(payload: dict):
from datetime import datetime, timezone

# createdAt is unix seconds; there is no completedAt.
completed_at = datetime.fromtimestamp(payload["createdAt"], tz=timezone.utc)
update_user_kyc(payload["userRef"], "verified", completed_at)
send_welcome_email(payload["userRef"])


def handle_review_required(payload: dict):
update_user_kyc(payload["userRef"], "pending_review", None)

flags = payload.get("flags") or []
priority = "high" if any(f["level"] == "err" for f in flags) else "normal"

# scores["liveness"] is nullable — comparing None to a number raises
# TypeError in Python, which would fail the task rather than the check.
liveness = (payload.get("scores") or {}).get("liveness")
weak_liveness = liveness is not None and liveness < 50

enqueue_for_review(
payload["verificationId"],
priority=priority,
weak_liveness=weak_liveness,
)

Note the difference from JavaScript: payload["event"] on a missing key raises KeyError, so a handler written against the wrong field name returns 500 and the event is retried six times and then parked as failed. Loud rather than silent — but the verdict is still lost until someone re-queues it.

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
if (!$this->verifySignature($sigHeader, $rawBody, $secret)) {
Log::warning('Invalid Veridia signature', [
'event_id' => $request->header('Veridia-Event-Id'),
]);
return response()->json(['error' => 'Invalid signature'], 401);
}

$payload = json_decode($rawBody, true);

$eventId = $payload['id'] ?? null;
$eventType = $payload['type'] ?? null;
if ($eventId === null || $eventType === null) {
// Not retryable. Acknowledge so it does not burn the retry window.
Log::warning('Veridia webhook missing id or type');
return response()->json(['ok' => true]);
}

// 2. Idempotency: insertOrIgnore against the primary key, so two
// concurrent retries cannot both get past the check.
$inserted = DB::table('webhook_log')->insertOrIgnore([
'event_id' => $eventId,
'event_type' => $eventType,
'verification_id' => $payload['verificationId'],
'payload' => json_encode($payload),
'received_at' => now(),
]);

if ($inserted === 0) {
return response()->json(['ok' => true]); // duplicate
}

// 3. Queue
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[trim($kv[0])] = trim($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);
}
}
<?php
// 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 Illuminate\Support\Facades\Log;
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['type']) {
'verification.approved' => $this->handleApproved(),
'verification.rejected' => $this->handleRejected(),
'verification.review_required' => $this->handleReviewRequired(),
default => Log::warning('Unknown Veridia event', [
'type' => $this->payload['type'],
'event_id' => $this->payload['id'],
]),
};
}

private function handleApproved(): void
{
User::where('user_ref', $this->payload['userRef'])->update([
'kyc_status' => 'verified',
// createdAt is unix seconds. There is no completedAt.
'kyc_completed_at' => now()->setTimestamp($this->payload['createdAt']),
]);
}

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',
]);

$hasHardFailure = collect($this->payload['flags'])
->contains(fn ($f) => $f['level'] === 'err');

ReviewQueue::push(
$this->payload['verificationId'],
$hasHardFailure ? 'high' : 'normal'
);
}
}

Route (routes/api.php):

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

Important: exclude the webhook route from CSRF protection. Add webhooks/* to $except in app/Http/Middleware/VerifyCsrfToken.php.

Cloudflare Workers

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
if (!await verifySignature(sigHeader, rawBody, env.VERIDIA_WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}

const payload = JSON.parse(rawBody);
const eventId = payload.id;
if (!eventId) return new Response('ok', { status: 200 });

// 2. Idempotency via KV, keyed on the event id.
//
// KV is eventually consistent, so this is a best-effort guard, not a
// lock: two retries arriving within a second of each other can both
// read null. Make the downstream effect idempotent too, or use a
// Durable Object if double-processing is unacceptable.
if (await env.WEBHOOK_LOG.get(eventId)) {
return new Response('ok', { status: 200 });
}

await env.WEBHOOK_LOG.put(eventId, JSON.stringify({ t: Date.now() }), {
expirationTtl: 86400 * 7, // 7 days
});

ctx.waitUntil(processVerification(payload, env));

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

async function processVerification(payload, env) {
switch (payload.type) {
case 'verification.approved':
return handleApproved(payload, env);
case 'verification.rejected':
return handleRejected(payload, env);
case 'verification.review_required':
return handleReviewRequired(payload, env);
default:
console.warn('Unknown Veridia event type', payload.type, payload.id);
}
}

async function verifySignature(header, rawBody, secret) {
const parts = {};
for (const piece of header.split(',')) {
const idx = piece.indexOf('=');
if (idx > 0) parts[piece.slice(0, idx).trim()] = piece.slice(idx + 1).trim();
}

const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;

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(`${timestamp}.${rawBody}`)
);
const expectedSig = Array.from(new Uint8Array(sigBytes))
.map(b => b.toString(16).padStart(2, '0'))
.join('');

// Constant-time comparison, length-checked first
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;
}

The seven-day KV TTL is comfortably longer than the 12.6-minute retry window, so it covers ordinary duplicates. It does not block a later event for the same verification, because the key is the event id — a reviewer approving a case a week after it went to review produces a different evt_* and is processed normally.

Observability

Log these on every delivery:

FieldWhy
idThe event identity. What support will ask for.
verificationIdTrace the case across your system
typeFilter and group by outcome
tenantIdMulti-tenant routing
signature_validCatch forgery attempts and secret mismatches
dedup_hitA rising rate means you are answering too slowly
processing_duration_msYour margin against the 10-second timeout
error_classTriage

A useful query, e.g. in Loki or Datadog:

rate(webhook_received{type="verification.review_required"}[5m])

That is your manual-review queue filling up in real time. Watch it against your reviewers' throughput — review cases stay pending until a person acts, and a backlog there is a backlog of blocked users.

Do not log the raw body by default. It contains fieldsExtracted. Log the field list above and redact the rest, or accept that your log store is now holding identity documents.

What's next