# Next.js Stripe Trap: The Retry That Double-Charges You

> Stripe retries webhooks until it gets a 200. Without dedupe, one payment becomes three orders. How BoringPush records each payment once.

Source: https://boringpush.com/blog/nextjs-stripe-boilerplate-webhooks

## Stripe retries. Your code double-charges.

Stripe sends a completed-checkout event and waits for your 200.

A timeout.

A deploy mid-request.

A slow start.

Stripe sends it again.

And again.

Three deliveries, one payment.

Without dedupe, each delivery creates an order, provisions access and fires the receipt email. The customer paid once and owns three lifetime licenses.

Your "once" pricing just became buy-one-get-two. You discover it at refund time, weeks later.

Every Next.js Stripe tutorial shows signature verification. Almost none shows the dedupe that makes retries safe. This is the missing half.

## Retry math on a naive handler

| Event | Naive handler | Idempotent handler |
|---|---|---|
| The first delivery, fast | One order | One order |
| The second delivery, a retry | A second order | The same order returned |
| The third delivery | A third order and three emails | The same order, no email |
| A deploy during the webhook | A partial write, unknown state | The re-run completes safely |
| The customer sees | Three charges to dispute | One charge, one receipt |

Retries are certain, not rare. Three reasons.

**Slow starts eat the timeout.** A function limit against a cold boot plus a database connect leaves no margin. Retries are the norm on cold paths.

**Deploys overlap webhooks.** A rolling reload can sever a webhook mid-flight. Stripe cannot tell "crashed" from "slow", so it retries both.

**A signature check does not dedupe.** Verification proves who sent the event. Not whether it is new. A valid signature arrives on every retry.

The money rules are short. Amounts come from your own product row, never from the payload. One order exists per payment, exactly once.

## What to do

Verify the signature before reading the body.

Check every incoming event against Stripe's signature, using the raw request body. Parsing the JSON first breaks the check silently. That is the classic trap.

An unsigned or tampered request is rejected. Nothing is written.

Dedupe on the payment reference before touching orders.

Record each payment under a unique reference, and check it first. A retry returns the original order instead of creating a second.

Put a unique constraint underneath, so two simultaneous deliveries cannot both slip through.

The amount comes from your product row. The amount in the webhook is informational and never authoritative.

Gate paid access behind one switch.

A visitor with no session is sent to log in. A signed-in visitor without a purchase is sent to pay. The webhook flips paid access exactly once per payment.

The paywall never grants what billing never recorded.

## The retry-storm drill

| Check | Action | Pass looks like |
|---|---|---|
| Signature enforced | Send a request without the signature | Rejected, nothing written |
| First delivery | Send a valid event | One order recorded |
| Retry five times | Replay the same event | The same order, no second one |
| A concurrent double | Send two at once | One order, the other deduped |
| Amount truth | Tamper with the payload's amount | The order uses your product-row price |

Fire the drill with Stripe's test tools before the first real customer. A billing path never drilled is a hypothesis with a bank account attached.

## The downside

Stripe's percentage costs real money from dollar one. Merchant-of-record rails like Dodo and LemonSqueezy trade a different fee shape for tax handling.

BoringPush implements all three behind one interface, so the switch is a setting, not a rewrite.

## Know this first

Stripe's percentage is real from dollar one, and tax is yours. BoringPush sends no receipt email.

Merchant-of-record providers trade fees for tax handling.

## Why BoringPush is the way to do this

Paid once. Recorded once.

BoringPush verifies every Stripe event, records each payment under a unique reference and grants access exactly once.

Retries stop being a problem.

## FAQ

### Does BoringPush handle subscriptions or one-time only?

Both shapes run through the same dedupe: one-time purchases and recurring payments each record once. The portal post covers the subscription side. It is the same guard with different event types.

### What if Stripe sends events out of order?

Dedupe keys on event identity, not sequence, so order never affects correctness for the same payment. For state changes such as trial to active to canceled, make sure a stale event cannot overwrite a newer one. Test the sequence in Stripe's test mode.

### How do I test webhooks locally without real cards?

Stripe's command-line tool forwards test events to your local machine, with triggers for completed, failed, refunded and disputed payments. No real card and no public address are needed. The checkout post walks the full local money loop.

### Should the webhook live on a VPS or serverless?

Either. The handler is stateless and fast: verify, look up, respond.

On a VPS it shares the box at no extra cost and sits next to the database. Locality beats topology.

### What happens when the webhook secret rotates?

Stripe lets two secrets live together during a rotation. Add the new one, keep the old, deploy, and remove the old after about a day. Sync the new value to the server with `bp secret sync`.

### When should I switch from Stripe to Dodo or LemonSqueezy?

When merchant-of-record tax handling outweighs Stripe's ecosystem, typically for global solo founders drowning in VAT and GST paperwork, or lifetime-deal sellers who want one-time-first rails. The switch is a provider setting plus remapping your product, tested on staging first.

## Keep reading

- [Take Money 3 Ways, Write Checkout Once](/blog/three-payment-rails) — Stripe, LemonSqueezy, and Dodo behind the same verbs
- [Stripe Sent It Twice. You Charged Twice. (Fix Inside)](/blog/webhook-idempotency) — the general law behind this post
- [Your Paywall Is 47 ifs. It Should Be 1 Boolean.](/blog/paywall-one-boolean) — the access side of exactly-once billing
