payments · tutorial · webhooks

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.

A grinning bill collector with a ledger under his arm, a caricature by Thomas Rowlandson, 1782-1800
A grinning bill collector with a ledger under his arm, a caricature by Thomas Rowlandson, 1782-1800 · Photo: Thomas Rowlandson, public domain, via Wikimedia Commons

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

+ 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