Your Webhook Worked. So Why Did the Customer Get Charged Twice?
A webhook firing successfully isn't the same as your system handling it correctly once. Here's why retries and duplicate delivery cause double charges, and how to build handlers that don't care how many times an event arrives.

Here's a support ticket I've seen more than once, in more than one business: "the payment webhook worked fine, everything logged as successful, but the customer was charged twice." The webhook didn't fail. That's the confusing part. It worked. It just worked twice.
This isn't a payments-only problem. It happens with order confirmations, stock updates, SMS notifications, anything triggered by a webhook. But payments make the cost obvious, so I'll use Stripe-style examples because most people recognise the shape of it.
Webhooks are a promise, not a guarantee
Stripe, and pretty much every webhook provider, will tell you upfront: they don't guarantee exactly-once delivery. They guarantee at-least-once. If your server doesn't respond with a 200 within a few seconds, or the response gets lost on the way back, or Stripe's own systems retry due to a timeout, you'll get the same event again. Sometimes twice. Sometimes five times over a few minutes.
Most webhook handlers I've inherited assume the opposite. They assume each event arrives once, so "payment succeeded, webhook received, fulfil order" is written as if that only ever happens once per payment. It doesn't. And the moment it fires twice, you either fulfil the order twice, send the confirmation email twice, or in the worst case, charge a card twice because the handler tries to capture a payment that's already been captured, or triggers a second charge through a downstream action.
Why retries happen more than people expect
A few common causes I see when I go digging:
- Slow handlers. If your endpoint does the database write, sends an email, calls another API, and updates a CRM, all before responding, that can easily take longer than the provider's timeout. The provider assumes it failed and retries, even though your server is still quietly finishing the job.
- Deploys and restarts. A webhook arrives mid-deploy, the server restarts before it responds, the provider retries once things are back up.
- Network blips. The handler succeeds, but the 200 response never makes it back. From the provider's side, that looks identical to a failure.
- Manual resends. Someone in the dashboard clicks "resend event" while debugging something unrelated, and it fires straight into production.
None of these are edge cases. They're normal operating conditions for any system that talks to the internet, which is all of them.
The fix: treat every event as if it might arrive twice
The reliable pattern is simple to describe and easy to skip under deadline pressure: every event has a unique ID, and you check whether you've already processed that ID before you do anything with it.
Stripe events all carry an id field, something like evt_1N.... The practical approach:
- Store processed event IDs in your own database, in a table with a unique constraint on the ID column.
- Before acting on an event, try to insert its ID. If the insert fails because it already exists, you've seen this one before. Stop there, respond 200, do nothing else.
- Only if the insert succeeds do you go on to actually fulfil the order, send the email, update the record.
The unique constraint matters more than the check-then-act logic. If you just query "have I seen this ID" and then insert afterwards, two near-simultaneous deliveries can both pass the check before either has inserted anything. That's the race condition version of the same bug, and it's the one that survives casual code review because it looks fine reading it top to bottom.
Idempotency isn't just for what you receive
The other half of this, which gets missed even by people who've handled the receiving side properly, is what you send onwards. If your webhook handler calls a payment provider's API to capture a charge, or calls another system to create an order, that outbound call needs its own idempotency key too. Stripe's API supports an Idempotency-Key header on requests specifically for this: if you send the same key twice, it returns the result of the first attempt instead of doing the action again. If you're building your own internal APIs, the same principle applies, give write operations a key derived from something stable, like an order reference, so a retried request can't duplicate the effect.
This is the bit that catches people out with payments specifically. Your webhook handler might correctly deduplicate the incoming event, but if the code path it triggers makes an un-keyed API call to actually take the money, a retry at that layer can still cause the double charge, even though your event handling was fine.
Respond first, process second
A related habit worth building in: acknowledge the webhook quickly, then do the actual work. Record that the event arrived, return 200 straight away, and push the real processing (emails, fulfilment, CRM updates) onto a queue or background job. This shrinks the window where a slow response looks like a failure and triggers an unnecessary retry in the first place. It doesn't remove the need for idempotency checks, retries will still happen for other reasons, but it cuts down how often you're relying on them to save you.
Where this tends to slip through
I see this most often in integrations bolted onto an existing system by whoever had time that week, or in AI-generated scaffolding that implements the happy path correctly but never asked "what if this fires twice." It's not that anyone's careless, it's that the failure mode is invisible until it isn't, and by then it's a customer on the phone rather than a line in a code review. If you're relying on webhooks anywhere near money, stock levels or anything a customer can dispute, it's worth having someone check the handler actually behaves once no matter how many times the event turns up. I cover this kind of thing when I do systems integration work, connecting the tools a business already uses without leaving gaps like this one between them.

