Skip to content

Draft — not published. This page is noindex and not in the sitemap until it's approved.

Guide · Python backend

Webhook Idempotency in FastAPI: Never Process an Event Twice

Key takeaways

  • Every webhook provider retries — at-least-once delivery is the contract, not a bug.
  • Store the provider's event ID with a database unique constraint before doing any work.
  • Duplicate delivery then becomes a cheap no-op, not a double-charged customer.
  • The database, not an in-memory set, is what makes this survive restarts and multiple workers.

By Prasanna Patil · updated

01

Retries are the contract

WhatsApp Business API, Twilio, Stripe, Razorpay — every webhook provider documents (or exhibits) at-least-once delivery. Your endpoint returning anything other than a fast 2xx gets retried, sometimes for hours. Network blips between you and the provider produce the same effect even when your code is perfect.

I hit this building WhatsApp AI agents for a client at Shivohini TechAI: the same inbound message arriving twice meant two AI replies to the customer. The fix wasn't more clever queueing — it was making the receiver idempotent.

02

The pattern

@router.post("/webhook", status_code=202)
async def webhook(event: IncomingEvent):
    # insert_if_new: INSERT with a unique constraint on the provider's event ID.
    # Returns the row only if this is the first delivery.
    message_id = db.insert_if_new(event.id, event.payload)
    if message_id:
        handle_message.delay(message_id)
    return {"id": event.id}   # always 202, even for duplicates
Illustrative sketch — names and settings are simplified.

Three details matter. The unique constraint lives in the database, so it holds across restarts and across every worker. The check-and-insert is one statement, so two concurrent deliveries can't both win. And duplicates still get a 2xx — failing them just teaches the provider to keep retrying.

03

Where teams get this wrong

  • Deduplicating in memory. A set of seen IDs dies with the process and doesn't exist for the second worker.
  • Acknowledging before persisting. If you enqueue before the row commits, a crash between the two loses the event; if you commit after enqueueing, a crash duplicates it.
  • Skipping the side-effect guard. The receiver is idempotent, but the task still needs its own guard (check the row's status before acting) in case the task itself is redelivered.
04

Limitations of this guide

It covers single-database idempotency. Multi-region writes, exactly-once delivery across systems, and providers that don't send a stable event ID each need more.

05

Frequently asked questions

What if the provider doesn't send an event ID?

Hash the stable fields of the payload (sender, timestamp, content) and use that as the dedup key. It's weaker — two genuinely different events with identical fields will collide — but for most providers the combination is unique enough in practice.

Is Redis not simpler for this?

Redis SETNX works and is fast, but it's a cache, not a ledger — a restart or eviction policy can drop your dedup keys. If a duplicate event costs money, the unique constraint belongs in PostgreSQL next to the data it protects.

Should I still return 202 for duplicates?

Yes. The provider only wants to know the delivery was received. A duplicate that returns an error gets retried forever, which is the exact outcome you're trying to prevent.

Webhooks misbehaving in production?

Describe the provider, the duplicate behaviour and where it hurts.