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.

Backend Engineerpythonwebhooksretry-logic

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

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

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

Return the Correct HTTP Status CodeBackend projectRepair Broken API PaginationBackend projectAdd CORS Middleware to an Express APIBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

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 →