Verifying signatures
Every webhook request is signed with your endpoint's secret so you can prove it came from Vuon and was not tampered with. Verify the signature before acting on any payload.
The recipe
Each request carries three headers:
vuon-webhook-id— the event id (idempotency key, stable across retries).vuon-webhook-timestamp— Unix seconds when this attempt was sent.vuon-webhook-signature— one or more signatures of the formv1=<hex>, comma-separated.
The signature is computed as:
HMAC-SHA256(secret, "{timestamp}.{raw request body}")
hex-encoded and prefixed with v1=. To verify:
- Read the raw request body — before any JSON parsing or re-serialization.
- Concatenate the timestamp header, a literal
., and the raw body. - Compute HMAC-SHA256 with your signing secret (the full
whsec_...string) and hex-encode it. - Compare against each comma-separated
v1=value using a constant-time comparison. The request is authentic if any signature matches. - Reject requests whose timestamp is more than 5 minutes from your current time to prevent replay.
The signature header can contain two signatures during the 24 hours after a secret rotation — one for the new secret and one for the old. Verifying against "any match" makes rotation seamless.
TypeScript
verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(params: {
secret: string;
rawBody: string;
timestampHeader: string;
signatureHeader: string;
}): boolean {
const timestamp = Number(params.timestampHeader);
if (!Number.isFinite(timestamp)) {
return false;
}
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (ageSeconds > TOLERANCE_SECONDS) {
return false;
}
const expected = createHmac('sha256', params.secret)
.update(`${timestamp}.${params.rawBody}`)
.digest('hex');
const expectedBuffer = Buffer.from(expected, 'hex');
return params.signatureHeader.split(',').some((candidate) => {
const value = candidate.trim().replace(/^v1=/, '');
const candidateBuffer = Buffer.from(value, 'hex');
if (candidateBuffer.length !== expectedBuffer.length) {
return false;
}
return timingSafeEqual(candidateBuffer, expectedBuffer);
});
}
With verifyWebhook from verify.ts above in scope:
Express usage
import express from 'express';
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook({
secret: process.env.VUON_WEBHOOK_SECRET!,
rawBody: req.body.toString('utf8'),
timestampHeader: req.header('vuon-webhook-timestamp') ?? '',
signatureHeader: req.header('vuon-webhook-signature') ?? '',
});
if (!ok) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// handle event.type / event.data here
res.status(200).send('ok');
});
Python
verify.py
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret: str, raw_body: bytes, timestamp_header: str, signature_header: str) -> bool:
try:
timestamp = int(timestamp_header)
except ValueError:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
for candidate in signature_header.split(","):
value = candidate.strip().removeprefix("v1=")
if hmac.compare_digest(value, expected):
return True
return False
Flask usage
from flask import Flask, request
import json
import os
from verify import verify_webhook
app = Flask(__name__)
@app.post("/webhook")
def webhook():
if not verify_webhook(
os.environ["VUON_WEBHOOK_SECRET"],
request.get_data(),
request.headers.get("vuon-webhook-timestamp", ""),
request.headers.get("vuon-webhook-signature", ""),
):
return "invalid signature", 401
event = json.loads(request.get_data())
# handle event["type"] / event["data"] here
return "ok", 200
Common mistakes
- Parsing then re-serializing the body before hashing. JSON serializers reorder keys and change whitespace; always hash the raw bytes you received.
- Comparing with
==. Use a constant-time comparison (timingSafeEqual,hmac.compare_digest) to avoid timing attacks. - Skipping the timestamp check. Without it, a captured request can be replayed indefinitely.