Signature verification
Every webhook carries a Veridia-Signature header. Verify it before trusting the body. Your endpoint is a URL on the public internet that grants people accounts; without signature verification, anyone who learns it can approve themselves.
The header
Veridia-Signature: t=1753142348,v1=4f8a3b9c01ee5d2f3b4a8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
| Key | Description |
|---|---|
t | Unix timestamp in seconds, set when this attempt was signed |
v1 | Hex-encoded HMAC-SHA256 of <t>.<raw_body>, keyed with your webhook secret |
The timestamp lives inside this header. There is no separate X-Veridia-Timestamp, and the header is not prefixed with X- — it is Veridia-Signature.
The algorithm
- Parse
tandv1from the header. - Build the signed payload: the bytes of
t, then., then the raw request body bytes. - Compute
HMAC-SHA256(secret, signedPayload)and hex-encode it. - Compare against
v1in constant time, after checking the two values are the same length. - Reject if
|now - t| > 300seconds.
All five steps must pass. Otherwise respond 401.
Compute the HMAC over the exact bytes you received. Parsing JSON and re-serializing it produces a different byte sequence — different key order, different whitespace, different number formatting — and the digest will not match. This is the single most common cause of "the signature never validates."
- Express:
express.raw({ type: 'application/json' }), notexpress.json() - Flask:
request.get_data() - FastAPI:
await request.body() - PHP:
file_get_contents('php://input')
Why 300 seconds is enough
Retries stretch over ~12.6 minutes, which suggests the tolerance ought to be wider. It should not be.
The dispatcher re-signs on every attempt, so the sixth retry arrives with a t that is seconds old, not twelve minutes old. A 300-second window never rejects a legitimate late retry. Widening it only extends how long a captured request stays replayable. Leave it at 300.
Node.js / Express
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const VERIDIA_SECRET = process.env.VERIDIA_WEBHOOK_SECRET;
// raw, not json — we need the exact bytes
app.post(
'/webhooks/veridia',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sigHeader = req.header('Veridia-Signature') || '';
const rawBody = req.body; // Buffer
if (!verifyVeridiaSignature(sigHeader, rawBody, VERIDIA_SECRET)) {
return res.status(401).send('Invalid signature');
}
res.status(200).send('ok');
const payload = JSON.parse(rawBody.toString('utf8'));
await processWebhook(payload);
}
);
function verifyVeridiaSignature(header, rawBody, secret) {
// Parse "t=...,v1=..."
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;
// Replay protection
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare lengths FIRST: timingSafeEqual throws a RangeError on
// mismatched buffer lengths. Without this, a request carrying
// `v1=ab` produces an uncaught exception and a 500 instead of a 401 —
// a one-line denial of service against your webhook endpoint.
const expectedBuf = Buffer.from(expectedSig, 'utf8');
const receivedBuf = Buffer.from(receivedSig, 'utf8');
if (expectedBuf.length !== receivedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
Python / Flask
import hmac
import hashlib
import time
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
VERIDIA_SECRET = os.environ["VERIDIA_WEBHOOK_SECRET"].encode()
def verify_veridia_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()
# compare_digest handles unequal lengths safely, unlike Node's timingSafeEqual.
return hmac.compare_digest(expected_sig, received_sig)
@app.route("/webhooks/veridia", methods=["POST"])
def veridia_webhook():
sig_header = request.headers.get("Veridia-Signature", "")
raw_body = request.get_data() # raw bytes, not parsed JSON
if not verify_veridia_signature(sig_header, raw_body, VERIDIA_SECRET):
return jsonify({"error": "Invalid signature"}), 401
payload = request.get_json()
process_webhook(payload)
return jsonify({"ok": True}), 200
Python / FastAPI
import hmac
import hashlib
import time
import os
import json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
VERIDIA_SECRET = os.environ["VERIDIA_WEBHOOK_SECRET"].encode()
def verify_veridia_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):
sig_header = request.headers.get("veridia-signature", "")
raw_body = await request.body()
if not verify_veridia_signature(sig_header, raw_body, VERIDIA_SECRET):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(raw_body)
process_webhook(payload)
return {"ok": True}
PHP
<?php
function verifyVeridiaSignature(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);
// hash_equals is length-safe and constant-time.
return hash_equals($expectedSig, $receivedSig);
}
// Handler
$secret = $_ENV['VERIDIA_WEBHOOK_SECRET'];
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_VERIDIA_SIGNATURE'] ?? '';
if (!verifyVeridiaSignature($sigHeader, $rawBody, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
processWebhook($payload);
http_response_code(200);
echo json_encode(['ok' => true]);
Note the header name in PHP: Veridia-Signature becomes $_SERVER['HTTP_VERIDIA_SIGNATURE'].
Test it manually with curl
Sign a body yourself and post it at your handler. The body below has the real payload shape, so this exercises your routing as well as your signature check — a test body with the wrong field names would let a broken handler look healthy.
#!/bin/bash
# replay.sh — post a Veridia-shaped webhook with a fresh signature
SECRET="$VERIDIA_WEBHOOK_SECRET"
URL="http://localhost:3000/webhooks/veridia"
BODY='{"id":"evt_00000000000000000000000000000001","type":"verification.approved","createdAt":1753142348,"tenantId":"tn_default_demo","verificationId":"vf_TESTREPLAY0000001","verdict":"approved","confidence":93.1,"userRef":"customer-12345","scores":{"ocr_confidence":78.0,"face_match":96.2,"liveness":91.5,"doc_quality":85.0,"mrz_valid":100.0,"name_match":88.0},"flags":[{"level":"ok","text":"auto_approved_all_checks_passed"}],"fieldsExtracted":{"full_name":"TEST USER","document_number":"0000000","date_of_birth":"1990-01-01","nationality":"PRY","document_type":"dni"},"latencyMs":3184}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -sS -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Veridia-Signature: t=${TS},v1=${SIG}" \
-H "Veridia-Event: verification.approved" \
-H "Veridia-Event-Id: evt_00000000000000000000000000000001" \
--data-raw "$BODY"
Three checks worth running once:
- As written, your handler should return
2xxand apply the approval. If it returns2xxwithout doing anything, you are switching on the wrong field. - Change one character of
SECRET. You should get401. - Replace the signature with
v1=ab. You should still get401— not a500. A500here means your comparison is missing the length check.
Note that this posts to http://localhost, which works because you are the sender. Real deliveries from Veridia require an https:// URL; use a tunnel for those.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Using parsed JSON instead of raw bytes | Signature never matches | express.raw() / request.get_data() / php://input |
timingSafeEqual without a length check | 500 and an uncaught RangeError on malformed input | Compare lengths first, return false |
| Direct string comparison | Timing attack | crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| No timestamp check | Captured requests replayable forever | Reject if |now - t| > 300 |
| Widening tolerance to cover retries | Weaker replay protection, no benefit | Every attempt is re-signed; keep 300 |
| Trimming or reformatting the body | Signature mismatch | Do not transform the body before hashing |
| Wrong secret | Everything fails | The secret is the one you set in Settings → Webhook |
Rotating the webhook secret
There is no dual-secret grace period. Saving a new secret in Settings → Webhook replaces the old one immediately, and deliveries signed with the old secret stop the moment you save.
Order the steps so your application is ready before the switch:
- Generate the new secret:
openssl rand -hex 32. - Deploy your application so it accepts both the old and the new secret (see below).
- Save the new secret in Settings → Webhook.
- Confirm deliveries are validating against the new secret.
- Deploy again, removing the old secret.
Steps 2 and 5 are what make this a zero-loss rotation. The grace window is yours to create in your own code — Veridia does not provide one, and a rotation done as "save in the dashboard, then deploy" fails every signature for the length of your deploy. Those failures are 401s, which the dispatcher treats as permanent: the events are not retried, and every verdict produced during that window has to be re-queued by hand or reconciled through the API.
function verifyWithAnySecret(header, rawBody, secrets) {
return secrets
.filter(Boolean)
.some(secret => verifyVeridiaSignature(header, rawBody, secret));
}
const valid = verifyWithAnySecret(sigHeader, rawBody, [
process.env.VERIDIA_WEBHOOK_SECRET,
process.env.VERIDIA_WEBHOOK_SECRET_OLD, // unset after step 5
]);
The secret is write-only in the dashboard: it is never displayed back to you. If you lose it, set a new one and follow the same sequence.
What's next
- Event types — the payload contract
- Examples — complete handler implementations
- Retries — delivery guarantees and recovery
- Webhooks — back to the section index