Designing reliable webhooks

API design: REST, HTTP, GraphQL and gRPC Mid-level Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

What do the three verify calls print?

Python 3.13
import hashlib
import hmac
import time

SECRET = b"whsec_test_only"


def sign(body: bytes, ts: int) -> str:
    msg = str(ts).encode() + b"." + body
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()


def verify(body: bytes, ts: int, sig: str, now: int) -> bool:
    if abs(now - ts) > 300:
        return False
    return hmac.compare_digest(sign(body, ts), sig)


now = int(time.time())
body = b'{"event": "invoice.paid", "id": "evt_1"}'
good = sign(body, now)
print(verify(body, now, good, now))
print(verify(body + b" ", now, good, now))
print(verify(body, now - 3600, sign(body, now - 3600), now))

Options
Hint 1 of 1

Two checks: the window and the HMAC.

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A True, False, False

Correct. The tampered body fails the HMAC; the hour-old timestamp is outside the 5-minute window.

verify checks two things: the timestamp is within 300 s of now, and the HMAC over timestamp and body matches. The extra space changes the body; the third call is signed correctly but is an hour old.

Why not the others?
  • B True, True, True. A single extra byte changes the HMAC.
  • C True, False, True. The third signature is valid, but the timestamp is too old.
  • D False, False, False. The first call uses a fresh, correct signature.

30-second answer

Sign every delivery: an HMAC of the timestamp and raw body with a per-customer secret, sent in a header, so receivers can verify origin and reject replays outside a few minutes. Deliver at least once: retry with exponential backoff for a day or more, and include an event id so receivers can deduplicate. Receivers should respond 2xx quickly and process asynchronously; events may arrive out of order, so include a timestamp or version.

Practise saying it · 40 s. No recording, just a timer

Design webhooks.

Say your answer out loud, then check which of these points you covered:

  • HMAC over timestamp + raw body
  • Replay window
  • At-least-once with event ids
  • Fast 2xx, async work
  • Out-of-order events

Deep dive

Verify with a constant-time comparison (hmac.compare_digest).

Sign the raw bytes; re-serialising JSON changes them.

Offer a way to replay or list missed events, and disable endpoints that fail for days with a notice.

Keep payloads small or 'thin' (id plus type) and let receivers fetch details.

Sign and verify a webhook Ran on Python 3.13.5 · mypy --strict
Python 3.13
import hashlib
import hmac
import time

SECRET = b"whsec_test_only"


def sign(body: bytes, ts: int) -> str:
    msg = str(ts).encode() + b"." + body
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()


def verify(body: bytes, ts: int, sig: str, now: int) -> bool:
    if abs(now - ts) > 300:
        return False
    return hmac.compare_digest(sign(body, ts), sig)


now = int(time.time())
body = b'{"event": "invoice.paid", "id": "evt_1"}'
good = sign(body, now)
print(verify(body, now, good, now))
print(verify(body + b" ", now, good, now))
print(verify(body, now - 3600, sign(body, now - 3600), now))

Common mistakes

  • Verifying a signature against parsed and re-serialised JSON.
  • String equality for signatures (timing leak).
  • Doing slow work before returning 200, causing timeouts and duplicate deliveries.

Senior take & follow-ups

Cover signing, replay window, retries with ids, quick 2xx and ordering.

Follow-up questions

Think of your answer first, then open the hint.

  1. Why include a timestamp in the signature?

    Show hint

    So an old captured request can't be replayed later.

  2. Thin or fat payloads?

    Show hint

    Thin events avoid leaking data and stale state; fat ones save a round trip.

Suggest a fix if something here is wrong or out of date.