event.id. Without a deduplication layer, your fulfillment runs twice and your buyer gets two download emails. The fix is a single Supabase table with a PRIMARY KEY on event_id and one INSERT ... ON CONFLICT DO NOTHING before your fulfillment logic. This post shows the exact schema and a complete production handler.
Key takeaways:
- Stripe retries failed webhooks up to 25 times over 3 days with exponential backoff
- Every retry sends the same payload with the same
event.id-- this is your dedup key - The fix is a 3-column Supabase table and one line of SQL before your fulfillment block
- The check goes before fulfillment, not after -- prevents partial-double-deliver edge cases
- Add a
statuscolumn to handle the case where fulfillment fails after the event is recorded - The complete handler is below -- copy it in, swap the fulfillment call, you're done
What actually happens when Stripe retries your webhook
Your server returns a 500. Maybe your Supabase connection pool was exhausted. Maybe a cold start on a serverless function added 3 seconds of latency and the request timed out. It doesn't matter why. What matters is what Stripe does next.
Stripe queues a retry. The first retry comes within seconds. Then it backs off: 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and so on up to 25 total attempts spread across 3 days. Each retry is a fresh HTTP POST to your endpoint with the exact same event payload.
Here is what that looks like in the Stripe Dashboard under Developers > Webhooks:
# Two delivery attempts for the same checkout.session.completed event
event_id: evt_1Px2AbCdEfGhIjKl
attempt 1: 2026-06-14T09:03:21Z HTTP 500 (your server returned an error)
attempt 2: 2026-06-14T09:03:28Z HTTP 200 (retry succeeded 7 seconds later)
# Both attempts hit your /webhook endpoint
# If your handler has no dedup check, it processes the order twice
The buyer paid once. They got two download emails. Your delivery log shows two fulfilled orders. This is the scenario you need to prevent.
The Supabase table (3 columns, 3 lines of SQL)
Look -- the entire deduplication mechanism fits in one migration. Create this table in Supabase:
-- Run this once in Supabase SQL Editor or as a migration
CREATE TABLE IF NOT EXISTS webhook_events (
event_id TEXT PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'processing'
);
That's it. Three columns:
- event_id: Stripe's
event.id(e.g.evt_1Px2AbCdEfGhIjKl). ThePRIMARY KEYconstraint is your dedup guarantee -- the database rejects duplicate inserts at the constraint level, not at the application level. - received_at: when you first saw this event. Useful for debugging and for finding stuck events.
- status:
processingwhen you start,completedwhen fulfillment succeeds. This column handles the edge case where fulfillment fails after the row exists (covered in the next section).
The Python check (5 lines before your fulfillment block)
In your webhook handler, add this before any fulfillment logic:
from supabase import create_client
import os
supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_KEY"])
def already_processed(event_id: str) -> bool:
"""Returns True if this event_id was already fulfilled."""
result = (
supabase.table("webhook_events")
.select("status")
.eq("event_id", event_id)
.eq("status", "completed")
.execute()
)
return len(result.data) > 0
def claim_event(event_id: str) -> bool:
"""Inserts the event_id. Returns True if we claimed it, False if it already existed."""
result = supabase.table("webhook_events").upsert(
{"event_id": event_id, "status": "processing"},
on_conflict="event_id",
ignore_duplicates=True,
).execute()
# upsert with ignore_duplicates=True inserts 1 row or 0 rows
return len(result.data) > 0
The pattern is: claim before you work. If another process already completed this event, skip it. If nobody has claimed it yet, insert the row and proceed.
The edge case: fulfillment fails after you write the event ID
Here's the scenario most tutorials skip. You claim the event (status: processing). Then your fulfillment call fails -- the email service throws an exception, Supabase is briefly down, your PDF generator times out. The event row exists in the database with status processing, but the buyer never got their file.
On the next Stripe retry, your claim_event call returns False (the row already exists), so you skip fulfillment. Now you've got the worst of both worlds: the buyer didn't get their file, and Stripe thinks you handled it.
The fix is a status check and a recovery path:
def handle_webhook(event_id: str, payload: dict):
# Step 1: Already fully completed? Return 200 immediately.
if already_processed(event_id):
return {"status": "duplicate_ignored"}
# Step 2: Claim the event (insert or skip if already processing)
claimed = claim_event(event_id)
if not claimed:
# Row exists with status 'processing' -- check if it's stuck
result = (
supabase.table("webhook_events")
.select("received_at")
.eq("event_id", event_id)
.execute()
)
if result.data:
age_seconds = (datetime.now(timezone.utc) -
datetime.fromisoformat(result.data[0]["received_at"])).seconds
if age_seconds < 300:
# Under 5 minutes -- another process is probably working on it
return {"status": "processing_in_progress"}
# Over 5 minutes stuck -- retry fulfillment
else:
return {"status": "race_condition_skip"}
# Step 3: Run fulfillment
try:
run_fulfillment(payload) # your logic here: send email, generate PDF, etc.
# Step 4: Mark completed
supabase.table("webhook_events").update(
{"status": "completed"}
).eq("event_id", event_id).execute()
return {"status": "fulfilled"}
except Exception as e:
# Leave status as 'processing' -- Stripe will retry and hit the 5-min recovery path
raise
This gives you at-least-once delivery with deduplication. The buyer gets their file. If there's a transient failure, the next Stripe retry picks it up via the 5-minute recovery path. If the event succeeds, all subsequent retries hit the already_processed guard and return 200 immediately.
The complete production handler (FastAPI)
Here's the full webhook endpoint, ready to drop into a FastAPI app:
from fastapi import FastAPI, Request, HTTPException
from supabase import create_client
import stripe
import os
from datetime import datetime, timezone
app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_KEY"])
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
def already_processed(event_id: str) -> bool:
result = (
supabase.table("webhook_events")
.select("status")
.eq("event_id", event_id)
.eq("status", "completed")
.execute()
)
return len(result.data) > 0
def claim_event(event_id: str) -> bool:
result = supabase.table("webhook_events").upsert(
{"event_id": event_id, "status": "processing"},
on_conflict="event_id",
ignore_duplicates=True,
).execute()
return len(result.data) > 0
def mark_completed(event_id: str):
supabase.table("webhook_events").update(
{"status": "completed"}
).eq("event_id", event_id).execute()
@app.post("/webhook")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
event_id = event["id"]
event_type = event["type"]
# Dedup gate
if already_processed(event_id):
return {"status": "duplicate_ignored", "event_id": event_id}
if event_type == "checkout.session.completed":
session = event["data"]["object"]
claimed = claim_event(event_id)
if claimed:
try:
# Your fulfillment logic here
customer_email = session.get("customer_details", {}).get("email")
fulfill_order(customer_email, session)
mark_completed(event_id)
return {"status": "fulfilled", "event_id": event_id}
except Exception as e:
# Stripe will retry; the 5-min recovery path handles stuck rows
raise HTTPException(status_code=500, detail=str(e))
else:
return {"status": "processing_in_progress", "event_id": event_id}
# For event types you don't handle, return 200 immediately
return {"status": "unhandled", "event_type": event_type}
def fulfill_order(email: str, session: dict):
# Your actual fulfillment: send download link, generate PDF, etc.
pass
How the deduplication scenarios play out
| Scenario | Without idempotency | With this pattern |
|---|---|---|
| Stripe sends event once, server returns 200 | 1 fulfillment (correct) | 1 fulfillment (correct) |
| Server returns 500, Stripe retries once | 2 fulfillments (buyer gets 2 emails) | 1 fulfillment (second attempt hits dedup gate) |
| Server cold start takes 35s, Stripe times out and retries | 2 fulfillments | 1 fulfillment |
| Fulfillment fails after event claimed (exception thrown) | Buyer never gets file, no retry possible | Stripe retries; 5-min recovery path reruns fulfillment |
| Stripe sends 25 retries (server down for 3 days, then back up) | Up to 25 fulfillments | Exactly 1 fulfillment (all retries hit the completed dedup gate) |
What to measure once this is running
Add a weekly query to your Supabase dashboard:
-- Find events that were retried (appeared in processing state more than once)
-- Proxy: rows where received_at and completed differ by more than 10 seconds
SELECT
event_id,
received_at,
status,
EXTRACT(EPOCH FROM (NOW() - received_at)) / 60 AS age_minutes
FROM webhook_events
WHERE status = 'processing'
AND received_at < NOW() - INTERVAL '10 minutes'
ORDER BY received_at;
If this query returns rows, you have stuck events. That means fulfillment is failing and Stripe is retrying. Fix the fulfillment bug, then manually update those rows to processing so the next Stripe retry picks them up.
A healthy system returns zero rows from that query. That's the metric: zero stuck events, zero duplicate fulfillments.
Three things to double-check before you ship this
- Use your Stripe webhook signing secret, not just the API key. The
stripe.Webhook.construct_eventcall verifies the signature on every request. Without it, anyone can POST to your/webhookendpoint and trigger fulfillment. The signing secret is in your Stripe Dashboard under Webhooks > your endpoint > Signing secret. - Set the Supabase service key, not the anon key. The service key bypasses Row Level Security. You need it for server-side writes from your backend. Keep it in an environment variable, never in client-side code.
- Test with Stripe CLI before going live. Run
stripe listen --forward-to localhost:8000/webhookandstripe trigger checkout.session.completed. Send the same trigger twice. Check yourwebhook_eventstable. You should see one row with statuscompletedand your fulfillment logs should show one run.
Look -- this pattern is not complicated. One table, one insert, one check. The hard part is remembering you need it at all before you hit the duplicate-fulfillment incident in production.
Next post: how we handle Stripe subscription lifecycle events (customer.subscription.created, invoice.paid, customer.subscription.deleted) with the same idempotency pattern, plus the retry logic for failed subscription renewals.
Written by the OperatorIQ engineering team. We build autonomous fulfillment systems for digital product businesses. Questions? hello@operatoriq.io.