Most webhook endpoints in production fail at least one of: signature verification, replay protection, idempotency, or retry handling. Each failure mode is a different attack surface or reliability bug. Here's the full pattern that handles all four for a Stripe-style webhook receiver.
1. Signature verification (HMAC-SHA256)
The sender (Stripe, GitHub, custom) signs the request body with a shared secret using HMAC-SHA256 and puts the result in a header (Stripe-Signature, X-Hub-Signature-256, etc.). Your endpoint must verify the signature before trusting any payload data.
Python:
import hmac, hashlib
def verify_signature(secret: bytes, body: bytes, signature_header: str) -> bool:
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
Critical: use hmac.compare_digest, never ==. Timing attacks against naive string comparison have been demonstrated in the wild.
Verify against the raw request body bytes, not the parsed JSON. Different JSON serializers produce different byte sequences for the same logical payload.
2. Replay protection (timestamp window)
Even a valid signature can be replayed by an attacker who captured a previous request. Add a timestamp to the signed payload and reject anything older than 300 seconds (Stripe's default).
import time
def is_fresh(timestamp_header: str, max_age_seconds: int = 300) -> bool:
sent_at = int(timestamp_header)
now = int(time.time())
return abs(now - sent_at) <= max_age_seconds
The timestamp must be part of what's signed — otherwise the attacker just changes the timestamp header. Stripe's signature scheme: signed_payload = "{timestamp}.{body}".
3. Idempotency
Webhook senders retry on failure. Your handler must produce the same outcome whether called once or seventeen times for the same event.
Pattern: every webhook delivery carries a unique event ID. Store processed event IDs in a database with a unique index. On every incoming webhook, attempt to insert the event ID; if the insert fails (duplicate), return 200 OK immediately without processing.
def handle_webhook(event_id: str, payload: dict):
try:
db.execute(
"INSERT INTO processed_events (event_id, received_at) VALUES (%s, NOW())",
(event_id,)
)
except UniqueViolation:
return 200 # already processed, idempotent ack
process_business_logic(payload)
return 200
Retention: keep event IDs for at least the sender's retry window (Stripe: 3 days, GitHub: 8 hours). After that, prune to keep the table small.
4. Retry logic (when YOU are the sender)
If your service sends webhooks to customers, you need retry logic that doesn't hammer their endpoints. Standard pattern: exponential backoff with capped retries.
retry_delays = [1, 2, 4, 8, 16] # seconds, doubling
max_retries = 5
for attempt, delay in enumerate(retry_delays):
response = send_webhook(url, payload)
if response.status_code == 2xx:
break
if response.status_code in (4xx except 429):
break # client error, won't fix itself
time.sleep(delay + random.uniform(0, 1)) # jitter
else:
mark_webhook_dead(payload_id)
Honor 429 (rate limit) responses by reading the Retry-After header. Don't retry 4xx errors other than 429 — they indicate a client bug that retries won't fix.
5. Order of operations in the handler
- Read raw body bytes (do NOT parse JSON yet)
- Verify signature against raw body
- Check timestamp freshness (reject if older than 300s)
- Parse JSON
- Extract event ID
- Insert event ID into
processed_events(idempotency check) - If duplicate: return 200 immediately, skip processing
- Process business logic
- Return 200 (or 500 if processing failed and you want a retry)
Steps 1-5 must complete in under 1 second. Step 8 can be slow if you queue it to a worker.
6. Common mistakes
- Parsing JSON before signature verification. Attacker payloads can crash your JSON parser before you check whether they're authentic.
- Using string equality instead of hmac.compare_digest. Timing attack vulnerable.
- Storing the secret in source code. Use env vars or a secret manager.
- Returning 200 on processing failures. The sender thinks delivery succeeded and won't retry.
- Returning 500 on duplicate events. The sender retries forever; pick 200.
- Processing synchronously when the work takes more than a few seconds. Senders time out at 10-30 seconds. Queue the work.
7. Testing
Use Stripe CLI's stripe trigger command or ngrok + the sender's dashboard "Send test webhook" feature. Verify your endpoint:
- Rejects invalid signatures with 401
- Rejects stale timestamps with 400
- Returns 200 on duplicate events without reprocessing
- Returns 200 on first successful processing
- Returns 500 on processing errors (and the sender retries)
Comparison
| Concern | Mechanism | Header / field | What to do on failure |
|---|---|---|---|
| Authenticity | HMAC-SHA256 | Stripe-Signature, X-Hub-Signature-256 | 401 Unauthorized |
| Replay protection | Timestamp + signature | Stripe-Signature t=... | 400 Bad Request |
| Idempotency | Event ID + unique index | event.id in payload | 200 OK (silent dedupe) |
| Retries (as sender) | Exponential backoff + jitter | n/a | 5 attempts then dead-letter |
| Retries (as receiver) | Return 2xx on success, 5xx for retry | n/a | Sender retries 5xx automatically |
FAQ
Why HMAC-SHA256 instead of SHA1?
SHA1 has known collision attacks. SHA256 is the current standard. Modern webhook senders (Stripe, GitHub, Shopify) all use SHA256.
How long should I keep event IDs for dedup?
At least the sender's retry window: Stripe 3 days, GitHub 8 hours, Shopify 4 days. We default to 7 days for safety, then prune.
Should I queue webhook processing?
Yes if business logic takes more than 2 seconds. Endpoint accepts the webhook (verify, dedupe, queue), returns 200. Worker processes the actual work. Prevents sender timeouts.
What if my queue worker fails?
The work is in the queue, retries by your queue's policy (typically 3-5 attempts). The webhook sender already got 200 — they won't retry, but your queue will retry your processing.
Need webhook integration done right?
We build production webhook handlers with all four protections baked in. Stripe, GitHub, custom sources.
Book a discovery call