# 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.

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`.

> **Warning:** 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

**Node.js (Express)**

```javascript
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);
});
```

**Python (Flask)**

```python
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 "", 200
```

## 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`](https://docs.versionseven.ai/api-reference/campaigns/rotate-webhook-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`](https://docs.versionseven.ai/api-reference/campaigns/rotate-webhook-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.
