Articles › Security

Receive webhooks safely: HMAC checks, replay window, fast 2xx, dedupe

A webhook URL is open to the internet. Verify the HMAC on the raw body in constant time, reject stale timestamps, answer 2xx fast, and handle duplicates and reordering.

Intermediate 9 min read October 22, 2026

What you will learn
  • Verify the signature over the exact raw body before parsing JSON, and compare with hmac.compare_digest, never ==
  • A timestamp inside the signed message stops old requests being replayed; Stripe's libraries allow 5 minutes
  • Return a 2xx within seconds and let a worker do the real work from a queue
  • Senders deliver more than once and in no fixed order, so dedupe by event id and never rely on arrival order

A webhook is another system (a payment gateway, GitHub, LINE) sending you an HTTP POST to say something happened, such as “this invoice was paid”. The catch: the receiving URL has to be public, so anyone who learns it can post to it. If you trust every request that arrives, someone else can tell your system to “ship the order” without paying. This is a receiver-side checklist of five guards every endpoint needs.

Tested on Python 3.12 (hmac, hashlib, sqlite3 from the standard library). Signature formats checked against the Stripe and GitHub docs as of October 2026.

The five guards on a webhook endpoint

GuardStopsWithout it
1. HMAC signature over the raw bodyforged requests, altered bodiesanyone can post fake events
2. Timestamp checka captured real request sent again (replay)an old request works forever
3. Fast 2xx, work in a queuetimeouts the sender counts as failureslost events, or a storm of redeliveries
4. Dedupe by event idthe sender retrying the same eventgoods shipped twice, emails sent twice
5. Don’t rely on arrival orderevents arriving out of orderstate rolled back to an older value
Read raw bodydo not parse before verifyingCheck HMAC + timefail = 400Save to inboxseen event id = skipReturn 200 nowwithin a few secondsWorker does the workretries without blocking the sender
The handler checks and records; a worker does the work

Guard 1: HMAC over the raw body, compared in constant time

You and the sender share a secret. The sender computes an HMAC-SHA256 of the body with it and puts the result in a header. You compute it again and compare. A match means it came from someone holding the secret and the body was not changed on the way.

StripeGitHub
HeaderStripe-Signature: t=...,v1=...X-Hub-Signature-256: sha256=...
Signed messagetimestamp + . + raw bodyraw body
AlgorithmHMAC-SHA256, hexHMAC-SHA256, hex
Timestamp in signatureyesno

The two rules that break most often:

  • Use the raw body, byte for byte. Stripe’s docs warn that many frameworks parse or alter the body before your code sees it (reordering keys, stripping whitespace, changing encoding), and that alone breaks the signature. In Express, register the webhook route before express.json(). In Flask, use request.get_data(), not request.json
  • Compare with a constant-time function: hmac.compare_digest in Python or crypto.timingSafeEqual in Node, not ==, which stops at the first differing character. That tiny timing difference can leak information. Stripe and GitHub both say this
import hashlib, hmac, time

TOLERANCE = 300  # seconds, the default in Stripe's libraries

def verify_stripe(raw: bytes, header: str, secret: str, now: float | None = None) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    ts = next((v for k, v in parts if k == "t"), None)
    sigs = [v for k, v in parts if k == "v1"]          # ignore v0 and any other scheme
    if ts is None or not ts.isdigit() or not sigs:
        return False
    if abs((now or time.time()) - int(ts)) > TOLERANCE:
        return False                                    # too old (or clock skew)
    signed = ts.encode() + b"." + raw                   # bytes exactly as received
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)

def verify_github(raw: bytes, header: str | None, secret: str) -> bool:
    if not header:
        return False
    expected = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

Why accept several v1 values? When you roll a secret, Stripe can keep the old one active for up to 24 hours and sends one signature per active secret. Ignoring every scheme except v1 is Stripe’s own advice, to prevent downgrade attacks. If the sender’s official library is available, use it; the code above is here so you know what it does inside.

A webhook secret is not your API key, and each endpoint should have its own, on the same principle as one credential per job. Keep it in the environment or a secret manager, never in code.

Guard 2: a timestamp against replay

If someone obtains one genuine request (from a log that stored the body and headers, say), its signature stays valid forever. Stripe therefore puts the timestamp inside the signed message: change the time and the signature breaks. All you do is reject requests whose timestamp is too far from now.

Worked example with a 300-second tolerance:

Header timestampYour clockGapResult
Real delivery17600000001760000180180 s ≤ 300accept
Replayed later17600000001760000400400 s > 300reject

Two things to know. First, Stripe generates a fresh timestamp and signature on every retry, so genuine retries are not rejected. Second, your clock must be right: keep NTP running, and never set the tolerance to 0; Stripe’s docs say 0 disables the recency check entirely.

GitHub has no timestamp in its signature. It suggests using the X-GitHub-Delivery header, unique per event, to reject repeats instead (guard 4); a redelivery you request keeps the same value.

Guard 3: answer 2xx fast, do the work in a queue

Stripe and GitHub agree: return a 2xx before any complex logic. GitHub expects a response within 10 seconds, or it drops the connection and counts the delivery as failed.

Worked example: a handler that does the work inline renders a PDF receipt (4 s), sends an email (3 s) and updates the accounting system (5 s). That is 4 + 3 + 5 = 12 seconds, past GitHub’s 10 every time. What happens next depends on the sender:

  • GitHub does not redeliver automatically. The event is gone until you redeliver it yourself
  • Stripe keeps retrying for up to three days with exponential backoff (live mode), while the first handler may well have finished its work. The result: the work runs twice

The fix is a handler that only verifies the signature, writes to an inbox table and returns 200, all in milliseconds, with a separate worker pulling from the inbox. A bonus: at the start of the month, when subscriptions renew together (Stripe’s own example), the worker goes at the pace your system can sustain.

A webhook handler has one job: accept the right sender and write it down somewhere safe. It does not do the work.

Guard 4: dedupe by event id

Webhooks are at-least-once: senders resend when unsure you received it, and Stripe says plainly that an endpoint may get the same event more than once. Put a primary key on the event id and let the database do the deduplication:

import json, sqlite3

db = sqlite3.connect("webhooks.db", isolation_level=None)
db.execute("""CREATE TABLE IF NOT EXISTS webhook_inbox (
  event_id     TEXT PRIMARY KEY,
  type         TEXT NOT NULL,
  payload      TEXT NOT NULL,
  received_at  INTEGER NOT NULL,
  processed_at INTEGER
) STRICT""")

def receive(raw: bytes, sig_header: str, secret: str) -> int:
    if not verify_stripe(raw, sig_header, secret):
        return 400
    event = json.loads(raw)                  # parse only after the signature passes
    db.execute("INSERT INTO webhook_inbox(event_id, type, payload, received_at) "
               "VALUES (?,?,?,?) ON CONFLICT(event_id) DO NOTHING",
               (event["id"], event["type"], raw.decode(), int(time.time())))
    return 200                               # a duplicate still gets 200 so retries stop

Sending the same correctly signed event twice gives 200 200 and one row in the table. Changing one character of the body gives 400. Moving the clock 301 seconds ahead makes verify_stripe return False.

A duplicate must get a 2xx, not a 4xx, or the sender thinks it failed and sends again. The work the worker does, such as reducing stock or calling another API, should carry its own idempotency key too (the idempotency key article), because Stripe admits it sometimes generates two Event objects with different ids for the same thing.

Guard 5: events arrive out of order

Stripe does not guarantee delivery order. Its docs give the example of creating a subscription, which yields customer.subscription.created, invoice.created and invoice.paid, but invoice.paid may arrive before invoice.created. Stripe also warns not to order by created: it is in seconds, so several events can share it.

What works in practice:

  • Treat the event as a signal. On invoice.paid, fetch the current invoice from the API and act on its real state rather than the payload
  • Let state only move forward, such as pending → paid → refunded. If a late event would move it backwards, ignore it
  • A missing referenced object is not an error. Create the row from the API, or defer it to a worker retry

Common mistakes

  • Parsing JSON before verifying, then json.dumps back for the check. The bytes no longer match, valid signatures fail, and worse, someone eventually disables the check because “it keeps breaking”
  • Comparing signatures with ==
  • A tolerance of 0, or the time check disabled while debugging and never re-enabled
  • One secret for every endpoint, or a test secret mixed with live. Stripe issues a separate secret per endpoint, and test and live differ
  • CSRF middleware rejecting the webhook. In Rails or Django, exempt that one route, not the whole app
  • Logging the full body with the signature header. Without a timestamp in the signature, that log is replay material
  • An endpoint that redirects (http → https, or adding a trailing /). Stripe counts any 3xx as a failure; register the final URL

Sources

FAQ

If I verify signatures, do I still need an IP allowlist?

Stripe recommends both. The signature is the main guard because it proves the content was not altered. An IP allowlist drops junk before it reaches the app; take the list from the sender's documentation rather than a copy you typed once and forgot.

What status should I return when the signature fails?

A 4xx, such as 400 (Stripe's examples) or 403 (GitHub's). Do not explain in the body what was wrong; keep the detail in your own logs.

Can the same thing arrive as two events with different ids?

Stripe says that in some cases two separate Event objects are generated, and suggests matching on the id in data.object together with event.type. So the work your worker does must itself be idempotent, not just guarded by the dedupe table.

Commands and settings were tested on sample systems. Try them on a test machine before production.