Verify a signature

Every delivery carries a Bridge-Signature header, an HMAC-SHA256 over the timestamp and the raw body. Check it before you trust the body.

Bridge-Signature: t=1792653600,v1=6f3c9a…

Bridge signs the exact bytes it sends, with a secret only you hold, and puts a timestamp inside the signature. Each property stops a different attack.

Property What it stops
HMAC over the raw body Anyone who can reach your URL posting a payload of their own. Change one digit of an amount and the signature fails.
A per-endpoint secret, never sent after it’s issued A leaked secret compromises one endpoint, not your account.
The timestamp is inside the signature, and you check a window Replay of a request that was genuine an hour ago.

The construction

signed_payload = timestamp + "." + raw_request_body
signature      = lowercase_hex( HMAC_SHA256( key = secret, message = signed_payload ) )
header         = "t=" + timestamp + ",v1=" + signature [ + ",v1=" + second_signature ]
  • The key is the whole secret string, including the whsec_ prefix, as UTF-8 bytes. Don’t hex-decode it.
  • timestamp is epoch seconds, not milliseconds.
  • Check every v1= value and accept if any matches. During a rotation the header carries two.
  • Compare in constant time: crypto.timingSafeEqual, hmac.compare_digest, MessageDigest.isEqual.

Verifying

const crypto = require("crypto");

function verify(header, rawBody, secret, toleranceSeconds = 300) {
  const t = Number((header.match(/t=(\d+)/) || [])[1]);
  if (!t || Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return [...header.matchAll(/v1=([0-9a-f]+)/g)]
    .some((m) => {
      const got = Buffer.from(m[1], "hex");
      return got.length === expected.length && crypto.timingSafeEqual(expected, got);
    });
}
import hmac, hashlib, re, time

def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    match = re.search(r"t=(\d+)", header)
    if not match or abs(int(time.time()) - int(match.group(1))) > tolerance:
        return False
    t = match.group(1)
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).digest()
    return any(hmac.compare_digest(expected, bytes.fromhex(v)) for v in re.findall(r"v1=([0-9a-f]+)", header))
public boolean verify(String header, String rawBody, String secret, Duration tolerance) {
  Matcher t = Pattern.compile("t=(\\d+)").matcher(header);
  if (!t.find()) return false;
  long timestamp = Long.parseLong(t.group(1));
  if (Duration.between(Instant.ofEpochSecond(timestamp), Instant.now()).abs().compareTo(tolerance) > 0) return false;
  byte[] expected = hmacSha256(secret, timestamp + "." + rawBody);
  Matcher v = Pattern.compile("v1=([0-9a-f]+)").matcher(header);
  while (v.find()) if (MessageDigest.isEqual(expected, HexFormat.of().parseHex(v.group(1)))) return true;
  return false;
}

A five-minute tolerance is the recommended window. The timestamp is stamped at each attempt, so a delivery retried hours later carries a fresh one, and a tight window is safe. Check your server’s clock is on NTP before you widen it.

Rotating a secret

  1. POST /webhook/endpoint/{reference}/secret with retireExistingAfterHours. You get the new secret, once.
  2. Both secrets sign every delivery for that window. Your code already accepts either, because it checks every v1=.
  3. Deploy the new secret at your own pace.
  4. When the window closes, the old secret is revoked automatically.

A third rotation while two secrets are active is refused with a 409. DELETE /webhook/endpoint/{reference}/secret/{secretReference} ends an overlap early, for a secret you believe has leaked. Revoking the last remaining secret is refused: an endpoint that can’t sign can’t deliver.