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.
- 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
| Guard | Stops | Without it |
|---|---|---|
| 1. HMAC signature over the raw body | forged requests, altered bodies | anyone can post fake events |
| 2. Timestamp check | a captured real request sent again (replay) | an old request works forever |
| 3. Fast 2xx, work in a queue | timeouts the sender counts as failures | lost events, or a storm of redeliveries |
| 4. Dedupe by event id | the sender retrying the same event | goods shipped twice, emails sent twice |
| 5. Don’t rely on arrival order | events arriving out of order | state rolled back to an older value |
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.
| Stripe | GitHub | |
|---|---|---|
| Header | Stripe-Signature: t=...,v1=... | X-Hub-Signature-256: sha256=... |
| Signed message | timestamp + . + raw body | raw body |
| Algorithm | HMAC-SHA256, hex | HMAC-SHA256, hex |
| Timestamp in signature | yes | no |
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, userequest.get_data(), notrequest.json - Compare with a constant-time function:
hmac.compare_digestin Python orcrypto.timingSafeEqualin 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 timestamp | Your clock | Gap | Result | |
|---|---|---|---|---|
| Real delivery | 1760000000 | 1760000180 | 180 s ≤ 300 | accept |
| Replayed later | 1760000000 | 1760000400 | 400 s > 300 | reject |
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.dumpsback 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
- Receive Stripe events in your webhook endpoint (verify signatures, replay, ordering, retries) — Stripe Docs
- Manage webhook endpoints (raw body, duplicates, async) — Stripe Docs
- Validating webhook deliveries — GitHub Docs
- Best practices for using webhooks — GitHub Docs
- Handling failed webhook deliveries — GitHub Docs
- hmac — Keyed-Hashing for Message Authentication — Python Docs
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.