30-second TL;DR Your Stripe webhook fires when a buyer pays. If your server returns a non-2xx status, Stripe retries the same event up to 25 times over 3 days. Every retry has the same 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:

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:

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.

This post is a follow-up to How We Wired Stripe Webhooks to Autonomous AI Fulfillment in 14 Days. That post covers the 3-component Python architecture. This post adds the idempotency layer that makes it production-safe.

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

  1. Use your Stripe webhook signing secret, not just the API key. The stripe.Webhook.construct_event call verifies the signature on every request. Without it, anyone can POST to your /webhook endpoint and trigger fulfillment. The signing secret is in your Stripe Dashboard under Webhooks > your endpoint > Signing secret.
  2. 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.
  3. Test with Stripe CLI before going live. Run stripe listen --forward-to localhost:8000/webhook and stripe trigger checkout.session.completed. Send the same trigger twice. Check your webhook_events table. You should see one row with status completed and 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.