WinFactor Docs

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

The 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

  1. Verify over the exact raw request bytes, before JSON parsing. Re-serialized JSON will not match.
  2. Compare with a constant-time function.
  3. Reject timestamps older than 5 minutes (replay protection).
  4. Dedup on X-WinFactor-Delivery — delivery is at-least-once.
  5. 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 "", 200

Rotation

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.

On this page