Next.js + Stripe Webhook Double-Credit Bug (2026)

Akshit Ahuja
Co-Founder & Lead Engineer
If you have a Next.js app taking payments with Stripe, there is a bug pattern I keep seeing in US and UK SaaS codebases.
A user pays once. Your app credits them twice.
It is not “Stripe being flaky”. It is your architecture. And yes, it will show up right when you finally get traction.
This post is about a specific failure mode in 2026 apps built on Next.js App Router: mixing Server Actions (or redirect return handlers) with webhook processing, then trying to patch it with a few “alreadyProcessed” checks.
I am going to be blunt: if you have two different code paths that can write “payment succeeded” into your database, you are shipping a race.
The exact bug: two writers for one payment
Most teams start with two flows:
1) The customer comes back from Stripe Checkout to your success URL, and you want to show “Paid” right away.
2) Stripe sends a webhook like checkout.session.completed or invoice.paid, and you also want to fulfill.
So you end up with:
- A “success page” action that calls Stripe, sees payment_status=paid, and credits the user.
- A webhook handler that also credits the user.
That is two writers.
The scary part is that both writers can be correct in isolation. The bug happens in the gap between them.
Here is one timeline:
- 00:00 user pays
- 00:01 user hits /success
- 00:02 your Server Action calls stripe.checkout.sessions.retrieve
- 00:03 your DB write commits credits
- 00:05 webhook arrives and also commits credits
And here is the one that hurts your brain:
- 00:00 user pays
- 00:01 webhook starts processing
- 00:02 user hits /success
- 00:03 your /success code checks “isProcessed?” and sees false because the webhook transaction is still running
- 00:04 /success code writes credits
- 00:05 webhook finishes and writes credits
That second one is why “check then insert” is not enough.
Why this shows up more in Next.js App Router (2026)
In 2026, a lot of teams build their billing glue with:
- Next.js Route Handlers for /api/webhooks/stripe
- Server Actions for “post-checkout finalize” or “upgrade plan”
- Vercel or similar serverless runtime
- Prisma + Postgres (often with PgBouncer)
This combo is fast to ship, but it pushes you toward short, convenient server entry points. Each entry point feels like its own little microservice.
Then the business pressure hits:
Founders hate “pending” states.
So the success page tries to finalize instantly, while the webhook also finalizes “just in case”. Two writers again.
Stripe retries make your bug look random
Stripe does retries. If your webhook endpoint times out, returns a 500, or drops the connection, Stripe will try again later.
That means you can see duplicates hours after the user paid. It feels spooky.
The fix is not “hope retries stop”. The fix is idempotency plus one logical writer.
Stripe also signs webhook payloads. You still need to verify the signature from the raw request body. That is table stakes.
A boring rule that saves you: webhooks only enqueue
If you want the simplest mental model, use this:
Your webhook handler should do three things:
1) Verify signature
2) Store the event id as “seen”
3) Enqueue work for a single worker to process
Then return 200 fast.
Do not:
- create users
- credit balances
- provision seats
- send “welcome” emails
Not inside the webhook request.
Yes, this feels like extra plumbing. It is. But it stops the bleeding.
The point is not that a webhook must be slow. The point is that the webhook must be dumb.
What “one logical writer” actually means
People hear “single worker” and think one Node.js process. That is not required.
You just need one logical writer per Stripe object id.
A clean approach looks like this:
- Use a queue (BullMQ, SQS, Cloud Tasks, whatever)
- Partition jobs by stripeCustomerId or stripeEventId
- Ensure only one job per key runs at a time
So you can scale workers horizontally, but you still serialize updates for the same customer.
If you are a small team, you can also do this with:
- A Postgres advisory lock keyed by stripeCustomerId
- Or a DB unique constraint + upsert patterns
Pick boring.
The real fix: idempotency at the database layer
Your “isProcessed” check should not be a read. It should be a write that can only succeed once.
For example:
- Create a table payment_events(id text primary key, processed_at timestamptz)
- When processing a Stripe event, first insert the event id
- If the insert fails (duplicate key), you stop
That is idempotency you can trust.
If you want the same idea for Checkout Sessions:
- Store stripeCheckoutSessionId as unique on your purchases table
- Or store stripePaymentIntentId as unique
Then every “credit user” operation must reference that unique id.
If your code can credit without a unique Stripe id, you are inviting trouble.
What about the success page?
I still like fast success pages. Users should not stare at a spinner.
But the success page should not be another writer.
Instead:
- Success page triggers a “sync” request that enqueues the same job your webhook enqueues
- Or it just polls your API for status until the worker finishes
The key idea: both paths enqueue the same work. Only one place writes.
A concrete architecture that works in production
Here is a design we ship for founders in the US, Canada, and Europe.
Components
- Next.js Route Handler: /api/webhooks/stripe
- Next.js Route Handler: /api/billing/sync (optional)
- Queue: BullMQ + Redis, or SQS
- Worker: Node process (can be on a small VM or a serverless worker)
- Database: Postgres
Webhook handler responsibilities
- Read raw body
- Verify Stripe signature
- Insert stripeEventId into payment_events
- Enqueue job: handleStripeEvent(stripeEventId)
- Return 200
Worker responsibilities
- Fetch event from Stripe (or trust payload, up to you)
- Acquire per-customer lock
- Upsert subscription state, purchases, credits
- Write an audit log row
“Sync” endpoint responsibilities
- Accept session_id or payment_intent
- Enqueue the same work
- Return 202
If you do this, your billing code stops being a maze.
Costs and timelines (realistic numbers)
If you are a founder reading this from the US or UK, here is what this fix usually costs.
If you already have Stripe webhooks, but they are messy
- 1-2 days: audit current event types and map them to business outcomes
- 2-4 days: build idempotency tables and unique constraints
- 2-3 days: refactor webhook to enqueue only
- 2-4 days: build a worker that can safely re-run jobs
- 1 day: load test and chaos test (timeouts, retries)
So call it 1.5 to 3 weeks for a small team if you want it done properly.
In dollars:
- US-based senior dev: often $120 to $200 per hour
- UK/EU senior dev: often $70 to $140 per hour
A typical “stop double-charging and double-crediting” cleanup lands around $6k to $18k.
If your database is already in pain (PgBouncer, connection limits, deadlocks), add a buffer.
If you are early and want the minimum safe setup
You can get a solid baseline in 2-4 days:
- Signature verification
- Event idempotency table
- Unique constraint on payment intent
- Queue + one worker
That is enough to sleep at night.
Testing: the three cases you must simulate
Most teams test one happy path. Billing bugs live in the unhappy paths.
Test these:
1) Webhook arrives twice (retry). Your worker should do nothing the second time.
2) Webhook arrives before the user returns to success page. Still correct.
3) Webhook arrives after success page triggers sync. Still correct.
Also test the “crash after DB write but before 200” case.
This is where idempotency tables earn their keep.
Common Next.js gotchas (that cause phantom bugs)
1) Parsing JSON before verifying signature
Stripe signature checks require the raw request body. If you parse the JSON first, you can break verification.
2) Doing long work in serverless webhook handlers
Serverless timeouts are real. If you do 20 seconds of work and you time out once, Stripe will retry and you will run the same work again.
3) Mixing “create subscription” logic across Server Actions and webhooks
If a Server Action flips plan state in your DB and the webhook also flips it, you will get weird plan states. Not always duplicates, but worse: confused customers.
My opinionated checklist (2026)
If you want to keep Stripe boring, do this:
- One writer. Always.
- DB-enforced idempotency (unique keys, insert-first).
- Webhook handlers return fast.
- Worker does all writes.
- Store an audit log for every billing change.
You can ignore this when you have 10 users. You will regret ignoring it at 1,000.
When you should bring in help
If you are seeing double credits, missing subscriptions, or “paid but no access” tickets, you probably have multiple issues:
- race conditions
- retries
- out-of-order events
- missing unique constraints
This is not a “one hour fix”. It is a small refactor.
If you want a team to untangle it quickly, bring people who have done Stripe incident response before.
We have rescued billing systems built in Next.js, Bubble, and custom stacks. The pattern is the same every time.
Final thought
Billing code should be boring.
If your Next.js app has two writers for Stripe outcomes, you are not boring. You are gambling.
Make the webhook dumb. Make the worker strict. Put idempotency in the database.
Your future self will thank you.
---
Related reading

Akshit Ahuja
Co-Founder & Lead Engineer
Backend systems specialist who thrives on building reliable, scalable infrastructure. Akshit handles everything from API design to third-party integrations, ensuring every product HeyDev ships is production-ready.