Webhooks
View as Markdown

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:

  1. Read the raw request body as bytes, before parsing any JSON.
  2. Compute the HMAC-SHA256 of those bytes with your secret, as lowercase hex.
  3. Compare sha256= followed by your digest with the header, using a constant-time comparison.
  4. 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);
});

Webhooks 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:

  1. 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.
  2. Send the new secret to POST /v1/campaigns/{campaign_id}/webhooks/{webhook_id}/secret.
  3. 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.