Check the signature
How to be sure a request came from us and wasn't changed, with code in Node, Python and PHP.
Anyone who learns your address could send it a request. Every request we send is signed with your webhook's secret, so you can tell ours from theirs. Check it before you trust what a request says.
How it's signed
The Webhook-Signature header looks like t=1793620800,v1=5257a8…:
tis the time we sent it, in seconds.v1is the hex of an HMAC-SHA256, with your secret as the key, of the text<t>.<body>: the time, a dot, then the raw body exactly as it arrived.
To check a request:
- Split the header into
tandv1. - Compute the HMAC of
t, a dot and the raw body. - Compare it to
v1in constant time. - Refuse the request if
tis more than 300 seconds from your clock. That stops someone sending an old request again.
Use the raw body, not JSON that your framework has parsed and written out again: a different spacing or order of keys gives a different signature.
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = parts.v1 ?? '';
return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}
Python
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
PHP
function verify(string $secret, string $header, string $rawBody, int $tolerance = 300): bool {
parse_str(str_replace(',', '&', $header), $parts);
if (!isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > $tolerance) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
Keep the secret safe
The secret starts with whsec_. Keep it on your server, never in a web page or a shared document. If it leaks, delete the webhook in Webhooks and make a new one: the new one has a new secret.