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:
checkout.session.completedarrives- You create the order and issue tickets
- Your response is slow, Stripe gives up waiting
- Stripe sends the same event again
- 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.
Written by Adarsh Gupta
Full Stack Developer building admin panels · backend apis from Jaipur, India.