# Verify signatures

> Check that a webhook came from Xholic: Standard Webhooks HMAC-SHA256 signatures with your whsec_ secret, with Node.js and Python examples.

Source: https://docs.xholic.ai/webhooks/verify/

Every delivery is signed the [Standard Webhooks](https://www.standardwebhooks.com) way, so you can use any Standard Webhooks library, or the few lines below.

1. Take the secret from Settings (`whsec_…`), drop the `whsec_` prefix, and base64-decode the rest. That's the HMAC key.
2. Build the signed content from the headers and the **raw** request body, exactly as received: `${webhook-id}.${webhook-timestamp}.${body}`.
3. Compute HMAC-SHA256 of it with the key and base64-encode the result.
4. `webhook-signature` holds one or more space-separated signatures like `v1,<base64>`. Accept the request if any `v1` one matches, compared in constant time.
5. Reject timestamps more than 5 minutes from your clock, so an old request can't be replayed.

Parse the JSON only after the signature checks out. Re-serialising the body first changes its bytes and breaks the signature.

## Node.js

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyXholicWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    "v1," + createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"),
  );

  return signatures.split(" ").some((candidate) => {
    const given = Buffer.from(candidate);
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

With Express, keep the raw body for the webhook route:

```js
import express from "express";

const app = express();

app.post("/webhooks/xholic", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verifyXholicWebhook(process.env.XHOLIC_WEBHOOK_SECRET, req.headers, rawBody)) {
    return res.status(401).end();
  }
  const event = JSON.parse(rawBody);
  res.status(200).end(); // Answer fast; do the work afterwards.
  handleEvent(event);
});
```

## Python

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_xholic_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    return any(hmac.compare_digest(candidate, expected) for candidate in signatures.split(" "))
```

In Flask, pass `request.get_data()` as `raw_body` and `request.headers` as `headers`.

## After verifying

- Reply `2xx` within 10 seconds, then process the event in the background.
- Dedupe by `webhook-id`: the same event can arrive more than once.
- Rotating the secret in Settings replaces it at once: every delivery after that, retries included, is signed with the new secret. Deliveries your server rejects in the meantime are retried, so update the secret on your side promptly.
