Lesson 15 / 27

Webhooks: Signing and Verifying

Sign outgoing events and verify them on receipt, with a timestamp to stop replays.

Anyone can POST to your URL

A webhook is an HTTP callback your API makes to a consumer's URL when an event occurs. Because the consumer's endpoint is public, it must be able to verify the sender. The standard approach: sign the request body with a shared secret using HMAC-SHA256, and send the signature (and a timestamp) in a header. The receiver recomputes the HMAC over the raw bytes of the body, compares with a constant-time function, and rejects timestamps older than a few minutes to prevent replay attacks. Webhook senders should also retry failed deliveries with backoff, include a unique event ID so receivers can de-duplicate, and let consumers rotate secrets. Receivers should respond 2xx quickly and process the event asynchronously.

Signing and verifying, run

I ran this plain-Python example. The signature is a 64-character hex digest. An untouched body within the time window verifies as ok; a body with one extra byte is rejected as a bad signature; the same valid message 4,000 seconds later is rejected as too old.

import hmac, hashlib, json, time, bisect

SECRET = b"whsec_demo"
def sign(body: bytes, ts: int) -> str:
    return hmac.new(SECRET, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
def verify(body, ts, sig, now, tolerance=300):
    if abs(now - ts) > tolerance: return "rejected: too old"
    return "ok" if hmac.compare_digest(sign(body, ts), sig) else "rejected: bad signature"
body = b'{"event":"payment.succeeded","id":"pay_1"}'; ts = 1_700_000_000
sig = sign(body, ts)
print(len(sig), verify(body, ts, sig, ts + 10), verify(body + b" ", ts, sig, ts + 10), verify(body, ts, sig, ts + 4000))

Output:

64 ok rejected: bad signature rejected: too old

Verify the raw body

Re-serialising parsed JSON changes whitespace and key order and breaks the signature. Compute the HMAC over the exact bytes received.

Quick check: Why include a timestamp in the signed data?

  • To reject old, replayed messages
  • To make the body smaller
  • Because HMAC requires dates
  • To speed up delivery
Answer

To reject old, replayed messages — Without freshness, an attacker could resend a captured valid message later.