Skip to main content

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:

KeyDescription
tUnix timestamp (seconds) when we sent the webhook
v1Hex-encoded HMAC-SHA256 of <timestamp>.<raw_body> using your webhook secret

The algorithm

To verify:

  1. Parse t and v1 from the header
  2. Concatenate: signedPayload = timestamp + "." + raw_request_body
  3. Compute: expectedV1 = HMAC_SHA256(yourSecret, signedPayload).toHex()
  4. Compare expectedV1 to v1 using a constant-time comparison
  5. Verify |now - timestamp| < 300 seconds (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

MistakeSymptomFix
Using parsed JSON instead of raw bytesSignature never matchesUse express.raw() / request.get_data() / php://input
Direct string comparison instead of constant-timeTiming attack vulnerabilityUse crypto.timingSafeEqual / hmac.compare_digest / hash_equals
Missing replay protectionCaptured requests can be replayed foreverReject if `
Using the wrong secretAll signatures failEach tenant has its own secret — check the dashboard for the active one
Trimming whitespace from the bodySignature mismatchDon't transform the body before computing HMAC

Rotating the webhook secret

To rotate without downtime:

  1. In the dashboard, click Rotate on your webhook secret
  2. Both the old and new secrets work for 15 minutes
  3. Update your environment variable to the new secret
  4. Deploy
  5. 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