Idempotent webhook receivers in 50 lines of Python


If your service receives webhooks from Stripe, GitHub, Slack, Shopify, or any modern SaaS, you have already lost events. You may not know it yet. Most teams discover the problem the same way I did: a customer asks why their subscription was charged twice, or why an order shows up three times in the dashboard, and you spend an afternoon walking through logs trying to figure out what happened.

The honest answer is almost always the same: the sender retried, your handler ran twice, and your code happily wrote two rows.

Idempotent webhook receivers are not a fancy distributed-systems pattern. They are a 50-line piece of code that every webhook endpoint should have, and most do not. This post walks through why duplicates happen, what the real failure modes look like, and a working Python + Postgres receiver that survives all of them.

Why duplicates happen — the three honest cases

When a sender (Stripe, GitHub, etc.) emits a webhook, your server has roughly 5–30 seconds to return a 2xx. If anything goes wrong inside that window, the sender retries. Retries are not the bug. The bug is your handler treating a retry as a new event.

Three common failure modes produce duplicates:

  1. Slow downstream. Your handler writes to a database that takes 12 seconds to commit during a backup window. Sender times out at 10 seconds and retries. Your second invocation succeeds. Now you have two rows.
  2. Network blip on the response. Your handler completes successfully and returns 200, but the response packet drops. Sender never sees the 2xx and retries. Same duplicate.
  3. Crashed worker. Your handler enqueues a background job and returns 200. The worker crashes mid-task. Sender already got a 2xx, so no retry — but your job left partial state. Different problem, same root cause: no idempotency key check.

The fix is a primary-key constraint on a column that the sender controls. Stripe sends an idempotency-key header. GitHub sends X-GitHub-Delivery. Shopify sends X-Shopify-Webhook-Id. Use it.

The 50-line receiver

This is a working FastAPI handler that survives all three failure modes above. It uses Postgres because the unique-constraint enforcement is what makes idempotency work — an in-memory dict will not survive a worker restart.

from fastapi import FastAPI, Request, HTTPException
import asyncpg
import hmac, hashlib, os, json

app = FastAPI()
DB_URL = os.environ["DATABASE_URL"]
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"].encode()

# Run once on startup:
# CREATE TABLE webhook_events (
#   event_id TEXT PRIMARY KEY,
#   source TEXT NOT NULL,
#   payload JSONB NOT NULL,
#   received_at TIMESTAMPTZ DEFAULT now(),
#   processed_at TIMESTAMPTZ
# );

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    body = await request.body()
    sig = request.headers.get("stripe-signature", "")
    expected = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig.split(",")[-1].split("=")[-1], expected):
        raise HTTPException(401, "bad signature")

    event = json.loads(body)
    event_id = event["id"]  # Stripe guarantees this is stable across retries

    conn = await asyncpg.connect(DB_URL)
    try:
        # The unique-constraint does the work.
        # ON CONFLICT DO NOTHING + RETURNING tells us if this was a duplicate.
        row = await conn.fetchrow("""
            INSERT INTO webhook_events (event_id, source, payload)
            VALUES ($1, 'stripe', $2)
            ON CONFLICT (event_id) DO NOTHING
            RETURNING event_id
        """, event_id, json.dumps(event))

        if row is None:
            # Duplicate — sender retried after we already processed it.
            # Return 200 so the sender stops retrying.
            return {"status": "already_processed"}

        # Real first delivery — do the actual work here.
        await handle_event(event, conn)

        await conn.execute(
            "UPDATE webhook_events SET processed_at = now() WHERE event_id = $1",
            event_id
        )
    finally:
        await conn.close()

    return {"status": "ok"}

async def handle_event(event, conn):
    # Your business logic — the unique constraint already guaranteed
    # this runs at most once per event_id.
    pass

The whole pattern is three things: signature check, INSERT with ON CONFLICT DO NOTHING, branch on whether the INSERT actually inserted. If the INSERT was a no-op, you return 200 without re-running the handler. The sender sees success and stops retrying.

What you do not need

You do not need Redis, Kafka, an inbox queue, or a saga orchestrator for this. Those are real tools, but they solve different problems (rate limits, fan-out, multi-step coordination). The “events fire twice” problem is solved by a primary-key constraint. The database is doing the deduplication for you. Use it.

You also do not need to think about retries on your side. The sender’s retry policy is fine. Your job is to make every retry safe to receive.

Three things this gets you for free

  1. Replay safety. If you ever need to replay events from the sender’s audit log (Stripe lets you, GitHub lets you, most modern SaaS does), the handler is already idempotent. You can replay 30 days of events without producing duplicate rows.
  2. Crash recovery. If your worker dies mid-handler before the UPDATE processed_at line, the sender retries, the INSERT detects the existing row, but processed_at IS NULL lets you tell that the work was never finished. Add a periodic job that re-runs handlers for any row with processed_at IS NULL and received_at < now() - interval '5 minutes'.
  3. Audit trail. Every event is now in your database. When a customer says “I never got the confirmation email,” you can query webhook_events and see exactly what arrived, when, and what payload it had.

The 5-minute test

Open a second terminal. Send the same webhook twice with curl:

curl -X POST localhost:8000/webhooks/stripe \
  -H "stripe-signature: t=...,v1=..." \
  -d @event.json
curl -X POST localhost:8000/webhooks/stripe \
  -H "stripe-signature: t=...,v1=..." \
  -d @event.json

The first call should return {"status": "ok"}. The second should return {"status": "already_processed"}. Your webhook_events table should have one row, not two. Your handle_event function should have run once.

If both calls return ok and you see two rows — the unique constraint is missing. If both return already_processed — the INSERT is hitting the conflict on the first call too, which usually means you are reusing event_id somewhere (development fixture data). Fix the constraint, fix the source data, and the test passes.

Where this came from

Across roughly a thousand cron runs on my Apify scrapers, I have watched what happens when an external service times out and retries the call into our pipeline. Twice in the last six months, downstream metrics-collection endpoints we own would have double-counted incoming events if the receiver had not had this exact pattern. The fix is small. The cost of not having it is “we have to manually clean the database” the first time a customer notices.

If you handle webhooks from any SaaS, run the 5-minute test today. The 50 lines above are not the elegant version — they are the version I have actually shipped to production and watched survive Stripe’s retry-storm during a brief downstream outage. Use them, replace handle_event with whatever your business logic is, and stop losing events.


Disclosure: This article was drafted with AI assistance and edited by a human author.

Disclosure: I maintain Apify scrapers that consume webhooks from various sources; links may direct to my Apify Store profile.

Questions or feedback? Email spinov001@gmail.com. More writing on web scraping, data pipelines, and operational resilience: blog.spinov.online. Telegram: t.me/scraping_ai.