How HMAC Webhook Signatures Work
When a payment processor sends a webhook to your server, how do you know it's real? Anyone who discovers your webhook URL could POST a fake "payment confirmed" event and trick your system into delivering a product without payment. HMAC signatures solve this problem.
The threat model
Your webhook endpoint is a URL on the public internet. If an attacker knows or guesses it, they can forge a POST request that looks exactly like a legitimate payment notification. Without signature verification, your server has no way to distinguish a real event from a fake one.
This isn't theoretical. Webhook forgery is a common attack vector against e-commerce systems. If your webhook handler fulfills orders without verifying signatures, you're trusting that nobody will ever discover your endpoint URL — a bet that gets worse with every log entry, error message, or configuration file that contains it.
How HMAC signing works
HMAC (Hash-based Message Authentication Code) is a way to prove that a message was created by someone who knows a specific secret, and that the message hasn't been tampered with in transit.
The process has two sides:
Sender (the payment processor):
- Takes the raw JSON body of the webhook
- Prepends a timestamp:
{timestamp}.{body} - Computes
HMAC-SHA256(secret, "{timestamp}.{body}")using a shared secret - Sends the signature in a header:
X-PayHook-Signature: t=1716800000,v1=abc123...
Receiver (your server):
- Reads the raw request body (important: don't parse and re-serialize)
- Extracts the timestamp and signature from the header
- Computes
HMAC-SHA256(secret, "{timestamp}.{raw_body}")using the same shared secret - Compares the computed signature to the one in the header using constant-time comparison
- Checks that the timestamp is within a tolerance window (e.g. 5 minutes)
If the signatures match and the timestamp is fresh, the webhook is authentic. If either check fails, reject it.
Why the timestamp matters
Without a timestamp, an attacker who intercepts a legitimate webhook could replay it later — hours, days, or weeks after the original event. Your server would compute the same HMAC and accept it as valid.
The timestamp prevents this. By including the current time in the signed payload, the verifier can reject any signature older than a few minutes. Even if an attacker captures a legitimate webhook in transit, they can't replay it after the tolerance window expires.
Most implementations use a 5-minute tolerance (300 seconds). This is wide enough to handle clock skew between servers but narrow enough to make replay attacks impractical.
Why constant-time comparison matters
A naive string comparison (=== or ==) short-circuits on the first mismatched byte. An attacker can measure response times to figure out how many leading bytes of their forged signature match the real one, then brute-force the rest byte by byte. This is called a timing attack.
Constant-time comparison functions — crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, hash_equals in PHP — always take the same amount of time regardless of where the mismatch occurs. Use them.
Example: verifying a PayHook webhook in Python
import hmac, hashlib, time def verify_payhook_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = {} for piece in header.split(","): k, _, v = piece.partition("=") if k and v: parts[k.strip()] = v.strip() if "t" not in parts or "v1" not in parts: return False try: ts = int(parts["t"]) except ValueError: return False if abs(int(time.time()) - ts) > tolerance: return False signed = f"{ts}.".encode() + raw_body expected = hmac.new( secret.encode(), signed, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, parts["v1"])
Common mistakes
- Parsing the body before signing.
JSON.parse(body)thenJSON.stringify()can reorder keys, change whitespace, or normalize Unicode. The HMAC was computed over the exact bytes the sender transmitted — verify against those exact bytes. - Using
==instead of constant-time comparison. Languages differ in how they expose this. Node:crypto.timingSafeEqual. Python:hmac.compare_digest. PHP:hash_equals. Ruby:OpenSSL.fixed_length_secure_compare. Go:hmac.Equal. - Ignoring the timestamp. Without the freshness check, a captured signature is valid forever.
- Hardcoding the secret in client-side code. The signing secret must stay server-side. If it leaks, anyone can forge valid signatures.
Further reading
PayHook uses this exact signing scheme — HMAC-SHA256 with a t=<ts>,v1=<hex> header format. The API documentation has complete verifiers in Node.js, Python, PHP, Ruby, and Go, plus notes on retry behavior and delivery guarantees.
Signed webhooks, built in.
Every PayHook webhook delivery is HMAC-signed with automatic retries.
Get started