Verify signatures
Every delivery is signed the Standard Webhooks way, so you can use any Standard Webhooks library, or the few lines below.
- Take the secret from Settings (
whsec_…), drop thewhsec_prefix, and base64-decode the rest. That’s the HMAC key. - Build the signed content from the headers and the raw request body, exactly as received:
${webhook-id}.${webhook-timestamp}.${body}. - Compute HMAC-SHA256 of it with the key and base64-encode the result.
webhook-signatureholds one or more space-separated signatures likev1,<base64>. Accept the request if anyv1one matches, compared in constant time.- 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
Section titled “Node.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:
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
Section titled “Python”import base64import hashlibimport hmacimport 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
Section titled “After verifying”- Reply
2xxwithin 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.
