Skip to main content

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
KeyDescription
tUnix timestamp in seconds, set when this attempt was signed
v1Hex-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

  1. Parse t and v1 from the header.
  2. Build the signed payload: the bytes of t, then ., then the raw request body bytes.
  3. Compute HMAC-SHA256(secret, signedPayload) and hex-encode it.
  4. Compare against v1 in constant time, after checking the two values are the same length.
  5. Reject if |now - t| > 300 seconds.

All five steps must pass. Otherwise respond 401.

Use the raw body, not the parsed JSON

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' }), not express.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:

  1. As written, your handler should return 2xx and apply the approval. If it returns 2xx without doing anything, you are switching on the wrong field.
  2. Change one character of SECRET. You should get 401.
  3. Replace the signature with v1=ab. You should still get 401 — not a 500. A 500 here 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

MistakeSymptomFix
Using parsed JSON instead of raw bytesSignature never matchesexpress.raw() / request.get_data() / php://input
timingSafeEqual without a length check500 and an uncaught RangeError on malformed inputCompare lengths first, return false
Direct string comparisonTiming attackcrypto.timingSafeEqual / hmac.compare_digest / hash_equals
No timestamp checkCaptured requests replayable foreverReject if |now - t| > 300
Widening tolerance to cover retriesWeaker replay protection, no benefitEvery attempt is re-signed; keep 300
Trimming or reformatting the bodySignature mismatchDo not transform the body before hashing
Wrong secretEverything failsThe 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:

  1. Generate the new secret: openssl rand -hex 32.
  2. Deploy your application so it accepts both the old and the new secret (see below).
  3. Save the new secret in Settings → Webhook.
  4. Confirm deliveries are validating against the new secret.
  5. 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