How to Build Webhook Delivery With Retries and Exponential Backoff
A fire-and-forget webhook sender drops payloads the moment a receiver has a blip. A production webhook system queues deliveries, retries with exponential backoff (1s, 2s, 4s), and exposes a status endpoint so you can see exactly what arrived and what didn't.
Why fire-and-forget webhooks lose data
A naive webhook sender does one POST and moves on. When the receiver is
temporarily down, returning a non-200, or slow to respond, the payload is gone
with no record and no retry. Real webhook systems (Stripe, GitHub, Twilio)
retry for hours and expose a full delivery log.
The retry loop with exponential backoff
Replace the single httpx.post with a loop that sleeps 2^attempt seconds
between tries:
import asyncio
import httpx
from datetime import datetime
MAX_ATTEMPTS = 5
async def deliver_with_retries(webhook_id: str, url: str, data: dict):
last_error = None
delivered = False
for attempt in range(MAX_ATTEMPTS):
try:
async with httpx.AsyncClient() as client:
r = await client.post(url, json=data, timeout=5.0)
if r.status_code == 200:
delivered = True
break
last_error = f"HTTP {r.status_code}"
except Exception as e:
last_error = str(e)
if attempt < MAX_ATTEMPTS - 1:
await asyncio.sleep(2 ** attempt) # 1s, 2s, 4s, 8s
return {
"webhook_id": webhook_id,
"url": url,
"status": "delivered" if delivered else "failed",
"attempts": attempt + 1,
"error": None if delivered else last_error,
"timestamp": datetime.utcnow().isoformat(),
}
asyncio.sleep(2 ** attempt) gives the delays 1s -> 2s -> 4s -> 8s.
The loop breaks immediately on the first 200, so successful deliveries
add zero extra latency.
Persisting delivery attempts
Store each result in a list (or a database table in production):
delivery_log: list[dict] = []
async def send_webhook(url: str, data: dict):
webhook_id = str(uuid.uuid4())
asyncio.create_task(
_run_and_record(webhook_id, url, data)
)
return webhook_id
async def _run_and_record(webhook_id, url, data):
result = await deliver_with_retries(webhook_id, url, data)
delivery_log.append(result)
Using asyncio.create_task fires the delivery in the background so
POST /webhooks/send returns immediately with the webhook_id.
The status API
Expose a GET /webhooks/status endpoint so callers can check delivery state:
from fastapi import FastAPI
app = FastAPI()
@app.get("/webhooks/status")
async def webhook_status():
return {"deliveries": delivery_log, "count": len(delivery_log)}
Each row in delivery_log includes the webhook ID, target URL, final status,
attempt count, any error message, and a timestamp - everything needed to
diagnose a failed delivery without digging through logs.
Edge cases to handle
- Timeouts - set
timeout=5.0on every attempt; a slow receiver should not block the retry loop indefinitely. - Invalid URLs - catch
httpx.InvalidURLand mark the deliveryfailedimmediately without retrying. - Idempotency - receivers must tolerate duplicate deliveries; a retry
after a network error may duplicate a payload the receiver already processed.
Pass a stable
X-Webhook-IDheader so receivers can deduplicate.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Implementing a retry loop with exponential backoff (asyncio.sleep(2 ** attempt))
- Firing webhook delivery as a background task with asyncio.create_task
- Building a status API to inspect delivery history and diagnose failures
FAQ
How do I add retry logic to a webhook sender in Python?
Wrap each delivery in a loop up to MAX_ATTEMPTS, catch HTTP non-200s and exceptions, and call asyncio.sleep(2 ** attempt) between tries (1s, 2s, 4s, ...). Break on the first success.
What is exponential backoff and why use it for webhooks?
Exponential backoff doubles the wait time between retries (1s, 2s, 4s, 8s...). It gives the receiver time to recover without hammering it with rapid-fire requests, which is the standard pattern used by Stripe, GitHub, and Twilio.
How do I track webhook delivery status in a FastAPI app?
Append a result dict to a shared list (or a database table) after each delivery attempt, including status, attempt count, and any error. Expose that list at GET /webhooks/status so callers and operators can inspect delivery history.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →