payments · tutorial · webhooks

Every Webhook Fires Twice (Dedupe or Double-Charge)

Every payment rail retries webhooks. Without a payment-reference guard you grant twice and email twice. The small dedupe that ends it.

John Tenniel's illustration of Tweedledum and Tweedledee, two identical round figures in helmets, with Alice between them, 1871
John Tenniel's illustration of Tweedledum and Tweedledee, two identical round figures in helmets, with Alice between them, 1871 · Photo: John Tenniel, public domain, via Wikimedia Commons

The customer paid once and owns three licenses

It surfaces as a support ticket. "I was charged once but got three receipt emails."

Or worse, as three access grants for one payment. Three lifetime seats for the price of one, compounding silently across every retry storm you never noticed.

The cause is never fraud. It is Stripe, or Dodo, or LemonSqueezy, delivering the same event three times because your first response was slow. And your handler treating each delivery as a new sale.

Retries are the provider doing its job. Duplicates are your handler failing at its one job.

The fix is small. Operating without it is a slow leak dressed as revenue.

Delivery guarantees and handler guarantees

Reality What the provider promises What you must add
Delivery count At least once, so retries Dedupe, for an exactly-once effect
Delivery order Any order, any timing Key on identity, not sequence
Concurrent doubles Possible, a race window A unique constraint as a backstop
Payload authority Informational amounts Price from your product row
Receipt emails Your code sends them Send only on the first write

Naive handlers fail exactly when money moves. Three reasons.

Timeouts are normal. Cold starts, deploys mid-request and slow DNS turn 200s into timeouts on the provider's side, while your write already committed. The retry arrives for an order that exists. Without lookup-first, you create its twin.

Concurrent delivery races the check. Two deliveries processed at once both miss the lookup, both insert, both grant. A database unique constraint closes the window the lookup alone leaves open.

Emails amplify the damage. One duplicate row is a quiet bookkeeping error. Three receipt emails are a customer-facing incident that triggers disputes.

Signature verification proves who sent it. Only dedupe proves it is new.

What to do

Look up before you create. Always.

Record every payment under a reference built from the provider's name and its own event or payment ID, like stripe plus the event ID. Before creating anything, check whether that reference exists.

If it does, return the existing order and report that it was already handled. No branch of the handler creates before checking.

Namespacing by provider keeps numeric IDs from colliding.

Back the lookup with a database constraint.

The reference is unique in the orders table. Two deliveries that arrive at the same moment both miss the lookup, but only one insert can win. The other becomes a constraint error that the handler reads back as "already handled".

Gate every side effect on the first write.

Granting access.

Sending an email.

Crediting an affiliate.

Logging analytics.

All belong inside the "this was new" branch.

The handler still returns a 200 on every delivery, because retries need success signals too. The world changes exactly once.

BoringPush does not send receipt emails itself. If you add them, put them inside that branch.

The retry drill all providers share

Check Action Pass looks like
Rejection Send an unsigned or forged request Rejected, nothing written
First write Send a valid event One order, access on
Replay five times Send the same event The same order, no side effects
Race Send two at the same moment One order
Tampered amount Edit the payload total The order uses your product-row price

Run it for each provider with its own replay tools.

Stripe's test tools.

LemonSqueezy's test mode.

Dodo's sandbox.

Do it before the first real customer. Billing paths earn trust through drills, not through reading.

The cost

One indexed lookup per webhook. Microseconds against a budget of hundreds of milliseconds. Plus the discipline of routing every side effect through the first-write check.

Skip it and the handler stays simpler while leaking duplicates worth far more than the saving.

Know this first

Dedupe adds one lookup per webhook. Side effects belong behind the first-write check.

BoringPush does not email receipts for you.

Why BoringPush is the way to do this

Retries are normal. Duplicates are not.

One rule for Stripe, LemonSqueezy and Dodo.

Look up before you create.

Constrain in the database.

Act only on the first write.

Built in.

FAQ

Is this just a database unique constraint?

Half of it. The constraint is the backstop that wins races and survives bugs. The lookup-first branch is the behavior: it returns existing rows, suppresses duplicate side effects and reports that it was already handled.

A constraint without the lookup throws errors on retries instead of answering them. A lookup without the constraint races. Ship both.

What should the payment reference be for each provider?

The provider's immutable event or payment ID, namespaced by provider. Never amounts, emails or timestamps, which are mutable or can collide. The reference must survive replays byte-identical and never repeat across distinct payments, and provider IDs satisfy both.

How long should dedupe records live?

Order records live forever anyway, because they are your books, so dedupe rides free on data you already keep. For non-order events, keep references for at least the provider's maximum retry window plus margin. Deleting dedupe state before the window closes reopens the hole it plugged.

Do I need a queue for webhook safety?

Not at solo scale. A synchronous verify, look up and respond takes milliseconds, well inside every provider's timeout.

Queues earn their keep at thousands of events per minute or with multi-minute provisioning flows. Start synchronous and graduate when your numbers say so.

What about checkout success pages? Dedupe those too?

Success pages must never grant access. They can be navigated to, replayed and forged.

Grant access only in the webhook path, and let the success page show the order state. Webhooks change the world, and pages display it.

How do refunds and disputes interact with dedupe?

They arrive as new events with their own references. Each one transitions the existing order, and none deletes it.

The order's history stays queryable for support and accounting, and access is removed as the transition requires. Dedupe guards creation, and state changes guard transitions.

Keep reading

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading