payments · webhooks · tutorial
Stripe Sent It Twice. You Charged Twice. (Fix Inside)
Payment providers retry webhooks. Dedupe on the payment reference or create duplicate orders and broken licenses. How BoringPush does it.

Every payment provider on the internet retries webhooks.
If your server takes too long to respond to Stripe, or a network hiccup interrupts the connection, Stripe delivers the same event again later. Dodo Payments and LemonSqueezy follow the same idea.
If your webhook handler creates a new order on every incoming request, one single $199 checkout can result in:
- Two duplicate user licenses
- Two welcome or receipt emails
- Two records competing for subscription renewal state
The fix is not praying for network perfection. The fix is idempotency.
Handling the same event twice has the same effect as handling it once.
Why retries are normal
Retries are not an edge case.
A deploy in the middle of a request.
A cold start.
A slow database call.
A brief network problem.
Each can turn a successful write into a timeout on the provider's side.
From the provider's point of view, your server never answered. So it sends the event again.
Your handler will meet the same event twice in the normal course of business. Designing for it is routine engineering, not paranoia. It costs one lookup per event.
What to do
Record each payment under a reference built from the provider and its own payment or event ID.
Before creating an order or granting access, check whether you have seen that reference. If you have, return success immediately and change nothing.
When Stripe replays the same event ten minutes later, the handler finds the existing order, skips the write and returns a 200.
One order. One grant. No surprises.
Back the lookup with a database constraint.
An application check alone can lose a race if two deliveries arrive in the same moment. So make the payment reference unique in the database.
Even if two requests arrive in the same millisecond, the second write is rejected cleanly, and the handler reads the existing order back.
Application check plus database guarantee. Either alone has a hole. Together they have none.
The three rules of webhook handlers
- Verify signatures first. Reject an invalid signature immediately with a 400. Never do database work before the request is authenticated.
- Return a 500 on transient errors. If your database connection fails, return a 500 so the provider knows to retry later.
- Never trust amounts from the request. Take the price from your own product catalog, not from the webhook body or the browser.
Common mistakes
Four mistakes cause most duplicate-payment bugs.
- Creating the order first and checking afterward.
- Using a reference that changes between deliveries, like a timestamp.
- Sending emails before the dedupe check.
- Granting access from the browser's success page.
Each is easy to spot in review once you know to look. Each is fixed by checking the payment reference first.
How to test it
Before your first real customer, send the same test event to your webhook five times. Check that exactly one order exists and that access was granted once.
Then send two copies at the same moment and check again.
Repeat in each provider's test mode.
Stripe's test tools.
LemonSqueezy's test mode.
Dodo's sandbox.
Finally, send a request with a bad signature. Confirm that nothing is written.
Where emails fit
BoringPush does not send receipts from the webhook itself.
If you add emails or any other side effect, like an affiliate credit, do it only on the first write. Never on a replay. The handler should still return success on a replay, because the provider is waiting for it.
A duplicate row is a quiet bookkeeping error. Three receipt emails are a customer-facing incident that invites disputes.
Success pages must not grant access
A success page can be revisited, replayed and forged.
Let it show what the webhook already recorded.
Nothing more.
Webhooks change the world.
Pages display it.
Know this first
The kit sends no receipt emails. If you add them, send only on the first write.
Test every provider in its test mode.
Why BoringPush is the way to do this
Paid once. Counted once.
BoringPush records each payment under a unique reference. A repeat changes nothing.
A database constraint backs it up.
FAQ
Why not return 200 immediately and process in the background?
Returning a 200 before writing means that if your server crashes during processing, the provider assumes the order was fulfilled and never retries. Always commit the order before returning a 200.
How long does Stripe retry webhooks?
Stripe retries failed deliveries for up to three days with exponential backoff. Check Stripe's documentation for the current schedule, because it can change.
What if the same payment arrives through two different providers?
Each payment reference includes the provider's name, so references from different providers never collide. Treat them as separate payments.
Does the success page grant access?
No. The success page only shows what the webhook already recorded. It can be revisited, replayed and forged, so it must never be the thing that grants access.
What about refunds?
A refund arrives as a new event with its own reference. It changes the existing order and removes access if nothing else keeps the user paid. It never deletes the original order.
Keep reading
- Every Webhook Fires Twice (Dedupe or Double-Charge) — the full version of this law
- Next.js Stripe Trap: The Retry That Double-Charges You — the Stripe adapter with this guard
- Take Money 3 Ways, Write Checkout Once — the architecture the guard protects
+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)
Command from your phone.
Built so autonomous AI agents build what you want without breaking what's already paying you.
- Built-in analytics — cookie-free mode, $0 forever, no PostHog bill
- SQLite ↔ Supabase: one setting, same field names
- Full digital marketplace & feedback boards included
- 3 payment rails: Stripe, LemonSqueezy & Dodo Payments
- Agent fences: lint gate, read-only data access, auto-rollback
- Ship from your phone via SSH & WireGuard/Tailscale in under a minute
$199
Get BoringPushOne-time payment · Lifetime updates · Unlimited projects.
Keep reading


