Double emails, double credits, or a "duplicate key" crash usually mean your handler assumes each event arrives exactly once, in order. Stripe guarantees neither.
What Stripe says
"Webhook endpoints might occasionally receive the same event more than once." Log the event IDs you've processed and skip repeats (Stripe docs).
"Stripe doesn't guarantee the delivery of events in the order that they're generated." Don't use created to order events or detect duplicates (source).
Your Checkout fulfillment function "might be called multiple times, possibly concurrently, for the same Checkout Session" (Stripe docs: Fulfill orders).
A pattern that holds up
Verify the signature, then insert event.id into a table with a unique constraint. If the insert conflicts, return 200 and stop.
Key fulfillment on the Checkout Session ID (or your order ID), not on the event. Record "fulfilled" with a unique constraint so a concurrent second call becomes a no-op.
When order matters, re-fetch the object from the API instead of trusting the event payload's snapshot.
Return 2xx quickly; slow handlers cause timeouts, which cause retries, which cause more duplicates.
Check yours in 10 seconds (free).
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events.