Skip to content
Available on Research and above

Verify webhook signatures

Check that a request really came from Planning Signal.

Every webhook request carries two headers: X-PlanningSignal-Timestamp and X-PlanningSignal-Signature, formatted as sha256= followed by a hex value. The signature is an HMAC-SHA256 of the timestamp, a full stop and the raw request body, made with your signing secret. Reject any request that does not match, and any with an old timestamp.

Use the raw request body exactly as received. Parsing and re-encoding it first will change it and the check will fail.

Node.js

import crypto from "node:crypto";

// Express: app.post("/hook", express.raw({ type: "application/json" }), handler)
function verify(req, secret) {
  const ts = req.get("X-PlanningSignal-Timestamp");
  const sig = req.get("X-PlanningSignal-Signature") || "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${req.body.toString("utf8")}`) // the RAW body
    .digest("hex");
  return sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Python

import hmac, hashlib, time

def verify(headers, raw_body: bytes, secret: str) -> bool:
    ts = headers["X-PlanningSignal-Timestamp"]
    sig = headers.get("X-PlanningSignal-Signature", "")
    if abs(time.time() - int(ts)) > 300:  # replay window
        return False
    expected = "sha256=" + hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(sig, expected)

Common problems

  • Signature never matches: you are probably hashing a parsed body, not the raw one, or using an old secret.
  • Rejected as too old: your server's clock may be wrong.
  • Deliveries show Failing or Disabled: your endpoint is not replying with a 2xx in time. Fix it, then use Send test.

Still stuck? Email support@planningsignal.co.uk.