Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

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:

  1. 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.
  2. Your success page might also try to fulfill. Mine did — the redirect page verified the session and called fulfillOrder too, 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.