Skip to content

Verify webhook signatures

Every webhook delivery carries an X-Webhook-Signature header: the hex-encoded HMAC-SHA256 of the raw request body using the webhook’s signing secret. Verify it before you trust anything else. An attacker who can reach your endpoint can forge a body; they cannot forge a valid signature without the secret.

The contract

X-Webhook-Signature = hex(HMAC_SHA256(secret, raw_body)) — available since v1.0.

  • Secret is the whsec_live_... value returned when the webhook was created (see Receive webhooks).
  • Signature is 64 lowercase hex characters.
  • Compare with a timing-safe equality function. Never use == or === — those leak the correct signature one byte at a time.

Node.js

// Express 4.x, Node 20+, @novavms/sdk optional
import express from 'express';
import crypto from 'node:crypto';
const SECRET = process.env.NOVAVMS_WEBHOOK_SECRET!; // 'whsec_live_7c4a1d9e...'
const app = express();
// Capture the raw body — do NOT use express.json() before this route.
app.post('/webhooks/novavms', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Webhook-Signature') ?? '';
const timestamp = req.header('X-Webhook-Timestamp') ?? '';
// Reject replays older than 5 minutes.
const age = Date.now() - Date.parse(timestamp);
if (!Number.isFinite(age) || age > 5 * 60 * 1000) {
return res.status(401).end('stale');
}
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
if (!ok) return res.status(401).end('bad signature');
const payload = JSON.parse(req.body.toString('utf8'));
// ... enqueue payload.data for async processing ...
res.status(204).end();
});

Python

# FastAPI, Python 3.10+, hmac + hashlib from stdlib
import hmac
import hashlib
import os
from datetime import datetime, timezone
from fastapi import FastAPI, Request, HTTPException
SECRET = os.environ["NOVAVMS_WEBHOOK_SECRET"].encode() # b'whsec_live_7c4a1d9e...'
app = FastAPI()
@app.post("/webhooks/novavms")
async def receive(request: Request):
raw = await request.body()
signature = request.headers.get("X-Webhook-Signature", "")
timestamp = request.headers.get("X-Webhook-Timestamp", "")
# Reject replays older than 5 minutes.
try:
sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(401, "bad timestamp")
if (datetime.now(timezone.utc) - sent_at).total_seconds() > 300:
raise HTTPException(401, "stale")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(401, "bad signature")
# Safe to parse now.
return {"ok": True}

curl (manual test)

You can sign a synthetic body locally to confirm your verifier is correct:

Terminal window
BODY='{"webhook_id":"a9f3c1e2-0000-4000-8000-000000000001","event_type":"alert"}'
SECRET='whsec_live_7c4a1d9e8b2f3a5c6d9e0f1a2b3c4d5e'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/novavms \
-H "X-Webhook-Signature: $SIG" \
-H "X-Webhook-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H "Content-Type: application/json" \
-d "$BODY"

Expected response: 204 No Content. Flip one byte in $BODY before signing and confirm you get 401.

Secret rotation

Rotating the signing secret invalidates the old one the moment you save the rotation — see Create and rotate webhooks for the rolling-cutover pattern that avoids downtime. On the receiver side, support two secrets during the migration window: verify against the new secret first, fall back to the old one, log which matched. Once all deliveries match the new secret, delete the old one from your config.

function verify(body: Buffer, signature: string, secrets: string[]): boolean {
for (const secret of secrets) {
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
if (
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))
) return true;
}
return false;
}