Skip to content
esc
↑ ↓ navigate↵ select
All posts

Idempotent Stripe Webhooks, or How to Never Double-Charge Anyone

3 min read

  • Stripe
  • FastAPI
  • PostgreSQL
  • Payments
On this page

On the event ticketing platform I work on, money moves through Stripe Connect split payments. The part that keeps that honest isn't the checkout page. It's the webhook handler, and one rule: processing the same event twice must do nothing the second time.

Why the Same Event Arrives Twice

Stripe guarantees at-least-once delivery, not exactly-once. If your endpoint times out, returns a 5xx, or the connection drops after you've done the work but before Stripe sees your 200, it retries. Sometimes hours later.

So the realistic sequence looks like this:

  1. checkout.session.completed arrives
  2. You create the order and issue tickets
  3. Your response is slow, Stripe gives up waiting
  4. Stripe sends the same event again
  5. Without a guard, you create a second order

Nothing is "broken" here. Retries are the system working as designed. The handler just has to expect them.

The Pattern: Record the Event ID First

Every Stripe event has a unique id (evt_...). Store it in a table with a unique constraint and let the database decide who wins.

CREATE TABLE processed_events (
  event_id    TEXT PRIMARY KEY,
  type        TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Then do the insert and the business logic in the same transaction:

# Simplified sketch: FastAPI + SQLAlchemy
@router.post("/webhooks/stripe")
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
    payload = await request.body()
    event = stripe.Webhook.construct_event(
        payload, request.headers["stripe-signature"], settings.STRIPE_WEBHOOK_SECRET
    )

    try:
        with db.begin():
            db.add(ProcessedEvent(event_id=event["id"], type=event["type"]))
            db.flush()  # unique violation raises here if we've seen it
            handle_event(db, event)  # create order, issue tickets, etc.
    except IntegrityError:
        return {"status": "duplicate"}  # already handled: still a 200

    return {"status": "ok"}

If two deliveries race each other, only one insert succeeds. The other hits the primary key, rolls back, and returns 200 so Stripe stops retrying.

Three Details That Matter

Verify the signature before anything else

construct_event checks the Stripe-Signature header against your endpoint secret. Skip it and anyone who finds the URL can mark orders as paid.

Return 200 for duplicates

A duplicate isn't an error. If you return 4xx, Stripe keeps retrying an event you've already handled correctly.

Keep side effects inside the transaction, or make them idempotent too

Emails and push notifications can't be rolled back. Either queue them after the commit, or give them their own idempotency key so a replay doesn't send the confirmation twice.

Order State Helps Too

The event table stops duplicate deliveries. A status column on the order stops duplicate transitions:

updated = db.execute(
    update(Order)
    .where(Order.id == order_id, Order.status == "pending")
    .values(status="paid")
)
if updated.rowcount == 0:
    return  # already paid (or cancelled): nothing to do

A conditional update like this is cheap and makes "pay an already-paid order" impossible, whichever path gets there first.

The Takeaway

Treat every webhook as "this might be the second time." A unique event ID, one transaction, and a 200 for duplicates turn retries from a billing incident into a log line.

Found this useful? Pass it on.

AK

Written by Adarsh Gupta

Full Stack Developer building admin panels · backend apis from Jaipur, India.

Say hi