Skip to content
Webhooks

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…:

  • t is the time we sent it, in seconds.
  • v1 is 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:

  1. Split the header into t and v1.
  2. Compute the HMAC of t, a dot and the raw body.
  3. Compare it to v1 in constant time.
  4. Refuse the request if t is 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.