Webhook Reliability: Building Event Delivery That Actually Works
Webhooks seem simple until they fail. Here's how to build reliable webhook delivery and consumption.
February 24, 2026 · 6 min · 1276 words · Rob Washington
Table of Contents
Webhooks are the internet’s way of saying “hey, something happened.” Simple in concept, surprisingly tricky in practice.
The challenge: HTTP is unreliable, servers go down, networks flake out, and your webhook payload might arrive zero times, once, or five times. Building reliable webhook infrastructure means handling all of these gracefully.
defmark_failed(event,reason):# Move to dead letter queue for manual reviewdead_letter_queue.push({"event":event,"reason":reason,"failed_at":time.time(),"attempts":event.attempt_count})# Notify webhook ownerifevent.failure_notification_url:send_failure_notification(event)
Give customers visibility into failed deliveries and a way to replay them.
fromfastapiimportFastAPI,Request,HTTPExceptionapp=FastAPI()@app.post("/webhooks/stripe")asyncdefreceive_webhook(request:Request):payload=awaitrequest.body()timestamp=request.headers.get("X-Webhook-Timestamp")signature=request.headers.get("X-Webhook-Signature")ifnotverify_signature(payload,timestamp,signature,WEBHOOK_SECRET):raiseHTTPException(status_code=401,detail="Invalid signature")# Check timestamp to prevent replay attacks (5 min tolerance)ifabs(time.time()-int(timestamp))>300:raiseHTTPException(status_code=401,detail="Timestamp too old")# Process webhook...
The sender is waiting. Don’t make them wait for your business logic:
1
2
3
4
5
6
7
8
9
10
11
12
13
@app.post("/webhooks/payments")asyncdefreceive_payment_webhook(request:Request):# Verify signatureevent=verify_and_parse(request)# Store immediatelyawaitstore_event(event)# Queue for processingawaittask_queue.enqueue("process_payment_event",event.id)# Respond fastreturn{"status":"received"}
Process the actual work asynchronously. The webhook endpoint should return in <5 seconds.
You might receive the same event multiple times. Handle it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
asyncdefprocess_payment_event(event_id:str):event=awaitget_event(event_id)# Check if already processedifawaitis_processed(event.idempotency_key):logger.info(f"Duplicate event {event_id}, skipping")return# Process with transactionasyncwithdb.transaction():# Do the workawaithandle_payment(event.data)# Mark as processed (same transaction)awaitmark_processed(event.idempotency_key)
The idempotency check and work must be in the same transaction to prevent race conditions.
Events might arrive out of order. Your logic should handle this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
asyncdefhandle_order_event(event):order=awaitget_order(event.data.order_id)# Check event timestamp against last processedifevent.created_at<=order.last_event_at:logger.info("Stale event, ignoring")return# Check state machine allows this transitionifnotcan_transition(order.status,event.type):logger.warning(f"Invalid transition: {order.status} -> {event.type}")# Maybe queue for reprocessing later?returnawaitapply_event(order,event)
deftest_duplicate_event_ignored():event=create_test_event()# First deliveryresponse1=client.post("/webhooks",json=event)assertresponse1.status_code==200# Duplicate deliveryresponse2=client.post("/webhooks",json=event)assertresponse2.status_code==200# Only processed onceassertget_processing_count(event.id)==1deftest_invalid_signature_rejected():event=create_test_event()response=client.post("/webhooks",json=event,headers={"X-Webhook-Signature":"invalid"})assertresponse.status_code==401
Reduces HTTP overhead but adds complexity in processing.
Webhooks look simple: POST some JSON to a URL. The reliability comes from handling everything that can go wrong: network failures, server errors, duplicates, out-of-order delivery, replay attacks. Build these patterns in from the start, and your webhook system will be one less thing keeping you up at night.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.