Skip to content

Verify signatures

Every delivery is signed the Standard Webhooks 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.

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:

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);
});
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.

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