SFHooks Docs

Webhook signatures

Verify HMAC-signed deliveries and rotate a signing secret safely.

Why verify

Every delivery is signed with your webhook's secret so you can confirm it really came from SFHooks and wasn't altered or replayed. Always verify the signature before trusting a payload.

The signature headers

Each delivery includes two headers:

  • X-SFHooks-Timestamp — the time the delivery was signed, in unix seconds.
  • X-SFHooks-Signature — shaped like t=<ts>,v1=<hex>, where v1 is the hex-encoded HMAC.

How to verify

How a signature is built

Compute the same value on your side and compare it to v1.

  1. Read the raw request body before parsing JSON — the signature covers the exact bytes you received.
  2. Compute an HMAC-SHA256 over the exact string <ts>.<raw_json_body> using this webhook's signing secret, then hex-encode it.
  3. Compare your result to the v1 value in a constant-time comparison.
  4. Reject the request if nothing matches or the timestamp is stale.
node.js
import crypto from "node:crypto"

// rawBody must be the exact bytes you received — verify BEFORE JSON.parse.
function verify(rawBody, headers, secret) {
  const ts = headers["x-sfhooks-timestamp"]
  const sig = headers["x-sfhooks-signature"] // "t=<ts>,v1=<hex>[,v1=<hex>]"

  // Reject stale timestamps (replay protection) — e.g. older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex")

  // A delivery may carry multiple v1 values during a secret rotation.
  return sig
    .split(",")
    .filter((p) => p.startsWith("v1="))
    .map((p) => p.slice(3))
    .some((v1) =>
      crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)),
    )
}

Rotating the secret

You can rotate a webhook's signing secret at any time from the Webhooks page. Rotation uses an overlap window: during it, the X-SFHooks-Signature header carries multiple v1 values — one for the current secret and one for the previous one.

Treat a request as valid if any v1 matches your configured secret (the snippet above already does this). That lets you deploy the new secret to your endpoint any time within the window with zero dropped deliveries.