How to prevent duplicate invoices when receiving Stripe webhooks

If you’ve built an integration where Stripe fires a webhook and you generate an invoice, sooner or later you’ll find two identical invoices for the same charge. Same customer, same amount, same date — but two different numbers in your accounting tool.

Duplicates aren’t a rare bug: they’re the default behavior if you don’t build the integration carefully. And they pollute your books faster than you’d think.

Why Stripe sends the same event multiple times

Stripe webhooks are designed to be «at least once»: they guarantee the event arrives at least once. They don’t guarantee only once. In practice that means:

  • If your server is slow to respond, Stripe retries.
  • If your server returns 500 for any reason, Stripe retries.
  • If there’s a brief network hiccup, Stripe retries.
  • In some cases, Stripe can send the same event spaced across several minutes.

Each retry is a fresh webhook hitting your system. If your logic is «webhook → create invoice», every retry creates a new invoice.

Symptoms you should watch for

  • Customers receiving two almost-identical invoice emails.
  • Your accounting tool showing duplicated revenue.
  • Monthly totals not matching Stripe’s charges.
  • Customers complaining «you invoiced me twice» when they only paid once.

The later you detect it, the more bad invoices you’ll need to correct manually.

What a well-built integration should do

Key idea is idempotency: every Stripe event has a unique identifier (payment_intent_id or charge_id depending on case). Before creating an invoice, you need to check whether an invoice already exists for that identifier. If it does, don’t create another.

This check isn’t expensive — it’s one query. But it has to be there, and implemented well (with a unique index at the system level, so two parallel webhooks can’t both win the race).

Cases that usually fail

  • Classic Stripe retries. Most common, covered by any integration with basic idempotency.
  • Multiple webhooks with different events for the same charge. payment_intent.succeeded and invoice.paid can both arrive. If you don’t distinguish, you create two invoices.
  • Logical duplication. Same charge Stripe sends as succeeded, then reconfirmed days later (for example, after a delayed 3D Secure). Two legitimate webhooks, but one invoice.
  • Concurrency. Two instances of your server receive the webhook at the same time, both check «does the invoice exist?», both say no, both create. Without a database-level lock, this happens.

Why DIY rarely catches it in time

When you build the integration, everything works in dev. The problem appears in production, with volume, and often takes weeks to notice — right when you already have hundreds of invoices and tracing duplicates is hard. Fixing requires:

  • Adding the idempotency check.
  • Creating the unique index in the database.
  • Migrating existing duplicates (and issuing credit notes for the bad ones).
  • Reviewing which customers may have received two invoices and explaining.

A reasonable mess to maintain.


PayPam handles Stripe webhooks with strict idempotency: every charge is tied to a unique identifier and never generates a duplicate invoice even if Stripe retries the event several times. You can see in the panel how many invoices were issued per source, guaranteed one invoice per charge.


Keep reading