I was building the coin system for Eyerone, a social platform with live streaming and virtual gifts: users buy a coin pack through Stripe Checkout, Stripe calls my webhook, and my server credits the coins.
It worked perfectly in testing. Then I noticed something in the database: some users had been credited twice for a single payment.
No hacking, no bugs in Stripe. Just a classic race condition in my own code. Here's what happened and the fix.
The naive version
This is roughly what my fulfillment logic looked like:
async function fulfillOrder(sessionId) { const order = await Order.findOne({ stripeSessionId: sessionId }); if (order.status === 'paid') return; // already handled ✅ ...right? order.status = 'paid'; await order.save(); await User.updateOne( { _id: order.userId }, { $inc: { coins: order.coins } } ); } Looks safe. We check the status before doing anything. So how did coins get credited twice?
Why it breaks
Two things are true in production:
- Stripe can deliver the same event more than once. Retries after a slow response, network hiccups, or manual resends from the dashboard all mean your endpoint must handle duplicates.
- Your success page might also try to fulfill. Mine did — the redirect page verified the session and called
fulfillOrdertoo, as a fallback in case the webhook was slow.
So two requests can arrive at almost the same time:
Request A: findOne → status = 'pending' Request B: findOne → status = 'pending' ← A hasn't saved yet Request A: save 'paid', $inc coins Request B: save 'paid', $inc coins ← double credit 💸 The problem is the gap between reading the status and writing it. Any check-then-update done in two separate queries has this gap.
The fix: make the check and the update one operation
MongoDB's findOneAndUpdate matches and modifies a document atomically. If we put the status condition inside the filter, only one request can ever win:
async function fulfillOrder(sessionId) { const order = await Order.findOneAndUpdate( { stripeSessionId: sessionId, status: 'pending' }, // only match if still pending { $set: { status: 'paid', paidAt: new Date() } }, { new: true } ); // Someone else already flipped it to 'paid' — nothing to do if (!order) return { alreadyProcessed: true }; await User.updateOne( { _id: order.userId }, { $inc: { coins: order.coins } } ); return { alreadyProcessed: false }; } Now the race plays out like this:
Request A: findOneAndUpdate(status: 'pending') → matches, sets 'paid' Request B: findOneAndUpdate(status: 'pending') → no match → returns null → exits Exactly one credit, no matter how many times the webhook fires.
Going one step further: a transaction
There's still one small gap: if the server crashes after marking the order paid but before crediting coins, the user paid and got nothing. Wrapping both writes in a transaction closes that:
import mongoose from 'mongoose'; async function fulfillOrder(sessionId) { const session = await mongoose.startSession(); let result = { alreadyProcessed: true }; try { await session.withTransaction(async () => { const order = await Order.findOneAndUpdate( { stripeSessionId: sessionId, status: 'pending' }, { $set: { status: 'paid', paidAt: new Date() } }, { new: true, session } ); if (!order) return; await User.updateOne( { _id: order.userId }, { $inc: { coins: order.coins } }, { session } ); result = { alreadyProcessed: false }; }); } finally { await session.endSession(); } return result; } Either both writes happen or neither does. (Transactions need a replica set — MongoDB Atlas clusters have this by default.)
Don't forget the webhook basics
The atomic update protects your data, but the endpoint itself also needs to be set up correctly:
import express from 'express'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const router = express.Router(); // Raw body is required for signature verification router.post( '/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { return res.status(400).send(`Webhook Error: ${err.message}`); } if (event.type === 'checkout.session.completed') { await fulfillOrder(event.data.object.id); } res.json({ received: true }); } ); export default router; Three things that tripped me up:
- Mount the webhook route before
app.use(express.json()). If the JSON parser runs first, the raw body is gone and signature verification always fails. - Always verify the signature. Without it, anyone can POST a fake "payment succeeded" event to your endpoint.
- Respond fast. Do the minimum work and return
200. Slow responses cause Stripe to retry — which is exactly how duplicates show up.
Bonus: a unique index as a safety net
Add a unique index on the Stripe session ID so the same checkout can never create two order documents:
const orderSchema = new mongoose.Schema({ userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, stripeSessionId: { type: String, required: true, unique: true }, coins: { type: Number, required: true }, status: { type: String, enum: ['pending', 'paid'], default: 'pending' }, paidAt: Date, }); Takeaways
- Webhooks are at-least-once, not exactly-once. Design for duplicates.
- A "check, then update" across two queries is a race condition waiting to happen.
- Put the condition inside the update filter with
findOneAndUpdate— the database becomes your lock. - Use a transaction when multiple writes must succeed together.
This pattern isn't Stripe-specific. Anywhere you have "do this exactly once" logic — rewards, payouts, inventory, email sends — the same atomic-filter trick applies.
Have you hit a webhook race condition before? How did you handle it? Let me know in the comments 👇
I'm Sithija, a full-stack developer and founder of Eyerone, working with React, Next.js, Node.js, MongoDB and Cloudflare. More of my work at sithijakalhara.com.