Signature verification
Every webhook from Veridia includes a Veridia-Signature header. You must verify this signature before trusting the body — otherwise anyone who knows your endpoint URL could forge events.
This page walks through the algorithm with copy-paste code for the four most common server stacks.
The signature header
Veridia-Signature: t=1714604000,v1=4f8a3b9c01ee5d2f3b4a8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
Two comma-separated key=value pairs:
| Key | Description |
|---|---|
t | Unix timestamp (seconds) when we sent the webhook |
v1 | Hex-encoded HMAC-SHA256 of <timestamp>.<raw_body> using your webhook secret |
The algorithm
To verify:
- Parse
tandv1from the header - Concatenate:
signedPayload = timestamp + "." + raw_request_body - Compute:
expectedV1 = HMAC_SHA256(yourSecret, signedPayload).toHex() - Compare
expectedV1tov1using a constant-time comparison - Verify
|now - timestamp| < 300seconds (5 minutes — prevents replay attacks)
If both checks pass, the request is authentic. Otherwise, reject with 401.
:::warning Use the raw body, not the parsed JSON You must compute the HMAC from the raw request body bytes, exactly as you received them. Re-serializing parsed JSON will produce a different byte sequence and the signature won't match.
In Express, this means using express.raw() middleware — not express.json().
In Flask, use request.get_data().
In PHP, use file_get_contents('php://input').
:::
Node.js / Express
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const VERIDIA_SECRET = process.env.VERIDIA_WEBHOOK_SECRET;
// IMPORTANT: use 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');
}
// Acknowledge immediately
res.status(200).send('ok');
// Now safe to parse and process
const payload = JSON.parse(rawBody.toString('utf8'));
await processWebhook(payload);
}
);
function verifyVeridiaSignature(header, rawBody, secret) {
// Parse "t=...,v1=..."
const parts = Object.fromEntries(
header.split(',').map(p => {
const [k, v] = p.split('=');
return [k, v];
})
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
if (!timestamp || !receivedSig) return false;
// Replay protection: reject if timestamp is more than 5 minutes off
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
// Compute expected signature
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`, 'utf8'),
rawBody,
]);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(expectedSig, 'hex'),
Buffer.from(receivedSig, 'hex')
);
}
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:
# Parse "t=...,v1=..."
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
# Replay protection
if abs(time.time() - timestamp) > 300:
return False
# Compute expected signature
signed_payload = f"{timestamp}.".encode() + raw_body
expected_sig = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
# Constant-time comparison
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 async if needed
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()
@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}
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)
PHP
<?php
function verifyVeridiaSignature(string $header, string $rawBody, string $secret): bool {
// Parse "t=...,v1=..."
$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;
}
// Replay protection
if (abs(time() - $timestamp) > 300) {
return false;
}
// Compute expected signature
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
// Constant-time comparison
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);
// Process
processWebhook($payload);
http_response_code(200);
echo json_encode(['ok' => true]);
Test it manually with curl
You can simulate a webhook locally to test your verification logic:
#!/bin/bash
# replay.sh - Replay a Veridia webhook with a fresh signature
SECRET="whsec_your_test_secret"
URL="http://localhost:3000/webhooks/veridia"
BODY='{"event":"verification.approved","verificationId":"vf_TEST_REPLAY"}'
TS=$(date +%s)
# Compute signature exactly as Veridia does
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "$URL" \
-H "Content-Type: application/json" \
-H "Veridia-Signature: t=${TS},v1=${SIG}" \
-H "Veridia-Event: verification.approved" \
-d "$BODY"
If your handler responds 200, your verification logic is correct. Try changing one byte of SECRET to see it reject with 401.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Using parsed JSON instead of raw bytes | Signature never matches | Use express.raw() / request.get_data() / php://input |
| Direct string comparison instead of constant-time | Timing attack vulnerability | Use crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| Missing replay protection | Captured requests can be replayed forever | Reject if ` |
| Using the wrong secret | All signatures fail | Each tenant has its own secret — check the dashboard for the active one |
| Trimming whitespace from the body | Signature mismatch | Don't transform the body before computing HMAC |
Rotating the webhook secret
To rotate without downtime:
- In the dashboard, click Rotate on your webhook secret
- Both the old and new secrets work for 15 minutes
- Update your environment variable to the new secret
- Deploy
- After 15 minutes, the old secret is permanently invalid
During the rotation window, your code can accept either secret:
function verifyVeridiaSignatureMulti(header, rawBody, secrets) {
return secrets.some(secret => verifyVeridiaSignature(header, rawBody, secret));
}
// Then:
const valid = verifyVeridiaSignatureMulti(
sigHeader,
rawBody,
[process.env.VERIDIA_WEBHOOK_SECRET, process.env.VERIDIA_WEBHOOK_SECRET_OLD]
);
What's next
- Event types — full schemas for each event
- Examples — complete handler implementations
- Webhooks overview — back to the webhook overview