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. timestampis 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
POST /webhook/endpoint/{reference}/secretwithretireExistingAfterHours. You get the new secret, once.- Both secrets sign every delivery for that window. Your code already accepts either, because it checks every
v1=. - Deploy the new secret at your own pace.
- 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.