Skip to content

Stripe Called My Webhook Twice, How to Stop Unlocking or Emailing the Customer Twice

6 min read

If one Stripe payment sent your customer two "welcome" emails, or granted access twice, the cause is almost always this: Stripe delivered the same webhook event more than once, and your handler ran its side effects each time. The fix is to record the event ID the moment it arrives and skip any event you have already processed. This is called making your handler idempotent, and it is expected work, not a bug you introduced.

Why this happens

Stripe does not promise to deliver each event exactly once. It promises to deliver it at least once. If your endpoint is slow, times out, or returns anything other than a 2xx status quickly enough, Stripe assumes you did not get it and retries. Network hiccups and cold starts on serverless functions trigger this constantly. So a single checkout.session.completed can land on your server two or three times, and each delivery looks completely legitimate, because it is.

Your AI agent built the happy path: "when payment succeeds, send the email and unlock the account." That code is correct for one delivery. Nobody wrote the part that says "but only do this once per payment," because you did not ask for it, and in the demo the event only ever arrived once.

How to check

  1. Open the Stripe Dashboard, go to Developers > Webhooks, click your endpoint, and look at recent event deliveries. If you see the same event ID with multiple delivery attempts, Stripe is retrying you.
  2. Check your response times. If a delivery shows a slow response or a non-2xx status, that is what caused the retry.
  3. Look at your own logs or email provider's sent log. Two identical sends with timestamps seconds apart, tied to one payment, confirms the duplicate ran your side effects twice.

The fix

  1. Store processed event IDs. Create a table (or collection) with a unique constraint on the Stripe event ID, for example a processed_events table with event_id as the primary key.
  2. Record before you act. At the top of your handler, try to insert the event ID. If the insert fails because the ID already exists, return 200 immediately and do nothing else. Only if the insert succeeds do you send the email and unlock access.
  3. Use the database's uniqueness, not an "if exists" check. A SELECT then INSERT has a gap where two concurrent deliveries both pass the check. A unique constraint makes the database reject the second one atomically, which is the whole point.
  4. Return 200 fast. Acknowledge the event first, then do slow work. If emailing takes four seconds and Stripe times out at three, you get retried even though everything worked.
  5. Test it. In the Stripe Dashboard, resend the same event manually (Developers > Events > the event > Resend). Your handler should process it the first time and cleanly skip every resend after.

The trap to avoid

Do not try to stop the duplicates by turning off Stripe's retries or by "checking if the email was already sent" with a fuzzy lookup. You cannot switch retries off, and you should not want to, because retries are what save you when a real delivery fails. The dedupe must happen on your side, keyed on the exact event ID Stripe gives you. Guarding on the customer's email or the amount will eventually skip a legitimate second purchase.

Where this fits

A double-charge email or a double-unlock is the kind of thing a real user notices on day one, and it quietly erodes trust before you have earned any. Idempotent webhook handling is standard payments hygiene, and it is invisible until traffic arrives, which is exactly why it slips through vibe-coded builds. The free Readiness Report checks whether your webhook handler dedupes and responds fast enough to avoid needless retries, and the Finishing Pass hands you the fixes in order. While you are in here, it is worth also reading what to do when the webhook never fires at all and how to prevent a genuine double charge.