Verifying webhook signatures
Check the X-Signature-256 header on each webhook delivery so your endpoint only acts on requests that came from Victoria AI.
Last updated
Anyone who learns your webhook URL can send a request to it. When a webhook has a signing secret, every delivery carries a signature you can check, so your endpoint only acts on genuine events.
How deliveries are signed
Victoria AI computes an HMAC-SHA256 of the raw request body, keyed with the webhook's secret, and sends the lowercase hex digest in the X-Signature-256 header, prefixed with sha256=.
To verify a delivery:
- Read the raw request body as bytes, before parsing any JSON.
- Compute the HMAC-SHA256 of those bytes with your secret, as lowercase hex.
- Compare
sha256=followed by your digest with the header, using a constant-time comparison. - If they don't match, reject the request with
401.
Verify the exact bytes you received. Parsing the JSON and serializing it again changes spacing and key order, and the signature won't match.
Examples
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.VICTORIA_WEBHOOK_SECRET;
function isValidSignature(rawBody, header) {
if (typeof header !== "string") return false;
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const expectedBytes = Buffer.from(expected);
const receivedBytes = Buffer.from(header);
return expectedBytes.length === receivedBytes.length && crypto.timingSafeEqual(expectedBytes, receivedBytes);
}
// express.raw keeps the body as the exact bytes that were signed.
app.post("/victoria/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get("X-Signature-256"))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200);
// Your own processing. Skip idempotency keys you've already handled.
processEvent(event);
});import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["VICTORIA_WEBHOOK_SECRET"].encode()
def is_valid_signature(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/victoria/webhooks")
def victoria_webhook():
raw_body = request.get_data() # the exact bytes that were signed
if not is_valid_signature(raw_body, request.headers.get("X-Signature-256")):
abort(401)
event = request.get_json()
process_event(event) # your own processing; skip idempotency keys you've already handled
return "", 200Webhooks without a secret
A webhook created without a secret receives unsigned deliveries, with no X-Signature-256 header. Add a secret with POST /v1/campaigns/{campaign_id}/webhooks/{webhook_id}/secret first, then turn on verification in your endpoint.
Rotating a secret
Setting a new secret with the same endpoint replaces the old one, and deliveries are signed with the new secret from then on. To rotate without rejecting any deliveries:
- Choose the new secret yourself, at least 16 characters, and deploy an endpoint that accepts a signature made with either the old or the new secret.
- Send the new secret to
POST /v1/campaigns/{campaign_id}/webhooks/{webhook_id}/secret. - Once deliveries verify against the new secret, remove the old one from your endpoint.
Replayed requests
The signature doesn't cover a timestamp, so a captured delivery would still verify if someone sent it again. Record each idempotency_key you process and ignore repeats. That also absorbs Victoria AI's own retries.