Stripe Webhooks Were Double-Crediting My Users — Fixed with One Atomic MongoDB Query
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 per
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.