Back to Blog
Engineering

How to Build Idempotent Webhook Handlers (So Retries Don't Wreck Your Data)

Webhooks are how one service tells your app that something happened — a payment succeeded, a subscription renewed, a repository got a push. They are simple to receive and easy to get subtly wrong, because of a fact most quick-start guides skip: the same webhook can arrive more than once. If your handler grants access, sends an email, or charges a card every time it runs, a duplicate delivery becomes a duplicate side effect. This is a practical, vendor-neutral walkthrough of building handlers that process each real event exactly once, no matter how many times it is delivered. The examples use Python and FastAPI for concreteness, but the pattern is the same in any language.

Why Webhooks Get Delivered More Than Once

Almost every webhook provider — payment processors, git hosts, messaging platforms — offers at-least-once delivery, not exactly-once. They guarantee they will keep trying until you acknowledge with a 2xx response, which means duplicates are a feature of the system, not a bug:

“Exactly once” does not exist at the network layer. The good news is you do not need it there — you can make processing exactly-once at the application layer, and that is what idempotency means.

The Core Idea: Deduplicate on the Event ID

Every well-designed webhook event carries a unique identifier — Stripe calls it id, GitHub sends a delivery id, and so on. That id is stable across retries: the same event redelivered carries the same id. So the entire strategy is three lines of logic:

The whole pattern in one breath

Before doing the work, check whether you have already processed this event id. If you have, do nothing and return success. If you have not, do the work and record the id — together, so they cannot drift apart. Use the sender's id, never one you generate on receipt, because only the sender's id is the same on a retry.

A First Attempt (and Why It's Not Enough)

The naive version reads exactly like the logic above:

@app.post("/webhooks/provider")
async def handle_webhook(request: Request):
    payload = await request.body()
    event = verify_signature(payload, request.headers)   # auth first — see below

    if already_processed(event["id"]):
        return {"status": "ok"}        # duplicate, already handled

    process_event(event)
    mark_processed(event["id"])
    return {"status": "ok"}

This works almost all the time, which is exactly what makes it dangerous. It hides a check-then-act race condition: if two copies of the same event arrive at the same moment — which is precisely what happens when a provider retries an event it thinks failed — both can run already_processed(), both see “no,” and both call process_event(). You have done the work twice.

The Robust Version: Let the Database Enforce It

The fix is to stop asking “have I seen this?” and instead claim the event atomically, letting a database unique constraint be the single source of truth. Create a table whose primary key is the event id:

# processed_events(event_id VARCHAR PRIMARY KEY, processed_at TIMESTAMP)

@app.post("/webhooks/provider")
async def handle_webhook(request: Request, db: Session = Depends(get_db)):
    payload = await request.body()
    event = verify_signature(payload, request.headers)

    try:
        db.add(ProcessedEvent(event_id=event["id"]))
        db.flush()                 # forces the INSERT; raises if the id already exists
    except IntegrityError:
        db.rollback()
        return {"status": "ok"}    # another delivery already claimed this event

    process_event(event)           # runs exactly once
    db.commit()                    # commit the work AND the dedup row together
    return {"status": "ok"}

Two things make this correct where the first attempt was not:

Verify the Signature First — Always

Before any of this, validate the provider's signature header against your webhook secret, on the raw request body, and reject anything that fails. A webhook endpoint is a public URL; without signature verification, anyone can POST fake events to it. Treat it like authentication — the same discipline as verifying a token in JWT auth. Verify first, then dedupe, then process.

Make the Work Itself Repeat-Safe

Deduplication is your primary defense, but defense in depth is cheap here. Where you can, design the side effects so that running them twice does no harm anyway:

Respond Fast, Process Slow

Remember that a slow response is itself a cause of duplicates. If your processing is heavy — generating a file, calling three other services — do not make the provider wait for it. Acknowledge the webhook immediately and hand the work to a background queue:

@app.post("/webhooks/provider")
async def handle_webhook(request: Request):
    payload = await request.body()
    event = verify_signature(payload, request.headers)
    enqueue_job(event)             # keyed by event id — idempotent there too
    return {"status": "ok"}        # ack in milliseconds; no timeout-driven retries

The same idempotency rule applies inside the job: dedupe on the event id before doing the work, because queues are also at-least-once. You have simply moved the exactly-once boundary to where the slow work actually happens.

The Checklist

A production-ready webhook handler

1. Verify the signature on the raw body, and reject failures. 2. Claim the event by inserting its id under a unique constraint — catch the duplicate and return 200. 3. Commit the work and the dedup row in one transaction. 4. Keep side effects repeat-safe (upserts, conditional grants). 5. Return 2xx fast; offload heavy work to a queue. 6. Dead-letter events that fail repeatedly for manual review, and log the event id on everything so you can trace a delivery end to end.

The Bottom Line

Idempotent webhooks are not about clever code — they are about admitting that delivery is at-least-once and pushing the “exactly once” guarantee into a place you control: a database unique constraint. Get that one move right and an entire category of 3 a.m. incidents — double charges, duplicate emails, doubled access — simply stops happening. The handler is maybe twenty lines; the value, as always, is in getting those twenty lines exactly right.

Webhooks That Just Work

This exact pattern — signature verification, event-id deduplication, and dead-lettering — ships wired-in with ShipKit, our FastAPI boilerplate. The implementation above is complete and yours to use; the boilerplate just saves you re-typing it on project number six.

Explore ShipKit
BW

Brandon Wigley

Founder of Wigley Studios. Building developer tools since 2018.

Previous: Inside UI Kit Packs All Articles