PayHook

How HMAC Webhook Signatures Work

Server room with network cables and blinking lights

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

  1. Takes the raw JSON body of the webhook
  2. Prepends a timestamp: {timestamp}.{body}
  3. Computes HMAC-SHA256(secret, "{timestamp}.{body}") using a shared secret
  4. Sends the signature in a header: X-PayHook-Signature: t=1716800000,v1=abc123...

Receiver (your server):

  1. Reads the raw request body (important: don't parse and re-serialize)
  2. Extracts the timestamp and signature from the header
  3. Computes HMAC-SHA256(secret, "{timestamp}.{raw_body}") using the same shared secret
  4. Compares the computed signature to the one in the header using constant-time comparison
  5. 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

PythonDjango / Flask webhook handler
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

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