Webhook Signatures
Verify that webhook deliveries genuinely come from WinFactor
Every webhook delivery from an organization with a signing secret carries:
X-WinFactor-Signature: t=1781100202,v1=5f9a3c…
X-WinFactor-Delivery: <unique event id>
X-WinFactor-Event: pricing.calculatedThe signature is HMAC-SHA256(secret, "{t}.{rawBody}") — the Stripe scheme. Your signing secret (whsec_…) lives in Settings → Integrations → Live pricing events; it is minted when you enable live pricing events and can be rotated there.
Verification rules
- Verify over the exact raw request bytes, before JSON parsing. Re-serialized JSON will not match.
- Compare with a constant-time function.
- Reject timestamps older than 5 minutes (replay protection).
- Dedup on
X-WinFactor-Delivery— delivery is at-least-once. - Once you have a secret, treat unsigned deliveries as failures. WinFactor signs every delivery from the moment the secret exists.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWinFactorSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false; // ±5 min
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: capture the RAW body — do not use a parsed req.body
app.post("/webhooks/winfactor", express.raw({ type: "*/*" }), (req, res) => {
const ok = verifyWinFactorSignature(
req.body.toString("utf8"),
req.header("X-WinFactor-Signature") ?? "",
process.env.WINFACTOR_SIGNING_SECRET,
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body);
// …handle event…
res.status(200).end();
});Python
import hashlib, hmac, time
def verify_winfactor_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
timestamp = int(parts.get("t", "0"))
if abs(time.time() - timestamp) > 300: # ±5 min
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))# Flask
@app.post("/webhooks/winfactor")
def winfactor_webhook():
if not verify_winfactor_signature(
request.get_data(), # RAW body
request.headers.get("X-WinFactor-Signature", ""),
os.environ["WINFACTOR_SIGNING_SECRET"],
):
abort(401)
event = request.get_json()
# …handle event…
return "", 200Rotation
Rotating the secret (Settings → Integrations) takes effect on the next delivery. To rotate without dropping events, accept both old and new secrets for a few minutes, then drop the old one.