Verifying signatures
How it's signed
The signed content is the webhook-id header, the webhook-timestamp header and the raw request body joined with dots: <id>.<timestamp>.<body>. The key is your secret without its whsec_ prefix, base64-decoded. webhook-signature holds v1, followed by the base64 HMAC-SHA256 of that content.
While a rotated secret is in its grace period, webhook-signature carries two space-separated signatures, one per secret; accept the delivery when any one matches.
- Verify the raw body exactly as received, before parsing JSON: re-serialising changes the bytes.
- Reject a webhook-timestamp more than 5 minutes from your clock, so a captured request can't be replayed later.
- Compare signatures in constant time.
- Answer 2xx quickly and do slow work afterwards: we wait 10 seconds.
With a Standard Webhooks library
The standardwebhooks packages on npm and PyPI (and libraries for most other languages) check the signature and the timestamp in one call and throw when either is wrong.
exampleverify
// npm install standardwebhooksimport { Webhook } from "standardwebhooks";const wh = new Webhook(process.env.DOTWICK_WEBHOOK_SECRET); // whsec_…// Express: app.post("/dotwick", express.raw({ type: "application/json" }), handler)function handler(req, res) { let event; try { event = wh.verify(req.body.toString("utf8"), { "webhook-id": req.header("webhook-id"), "webhook-timestamp": req.header("webhook-timestamp"), "webhook-signature": req.header("webhook-signature"), }); } catch { return res.status(400).end(); } res.status(204).end(); // answer first, then work queue.add(event.type, event);}By hand
No dependency: the whole check is a few lines of the standard library.
exampleverify
import { createHmac, timingSafeEqual } from "node:crypto";function verifyDotwick(secret, headers, rawBody) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"] ?? ""; if (!id || !timestamp) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest(); return signatures.split(" ").some((entry) => { const [version, signature] = entry.split(","); const received = Buffer.from(signature ?? "", "base64"); return ( version === "v1" && received.length === expected.length && timingSafeEqual(received, expected) ); });}