AhmadRaza365 Logo

AhmadRaza365

Blog Post

Designing an Order System That Never Duplicates: Idempotency, Webhooks, and Retries Explained

April 1, 2026
Designing an Order System That Never Duplicates: Idempotency, Webhooks, and Retries Explained

When an order duplicates, it’s not “just a bug.” It’s a revenue + accounting incident:

  • Customers get double-charged (refund + chargeback risk)
  • Inventory gets decremented twice (overselling)
  • Fulfillment ships twice (direct loss)
  • Finance can’t reconcile payouts (Stripe/PayPal reports won’t match)

In MERN/Next.js ecommerce builds, duplicates usually happen during network retries, double-clicks, mobile connection drops, and webhook re-deliveries.

If you want an order system that never duplicates:

  1. Use idempotency keys for every “create order / confirm payment” call.
  2. Make your database enforce uniqueness (unique indexes) so duplicates are impossible, even if your code is wrong.
  3. Treat webhooks as at-least-once delivery and make webhook handlers idempotent.
  4. Never do side effects inline (emails, stock sync, shipping labels). Put them on a queue with retries.
  5. Log + monitor: you should be able to answer “why did this order exist?” in under 2 minutes.

This post is the playbook I implement on client stores to stop duplicate orders and tame webhook chaos.


The real root cause: “at least once” happens everywhere

A reliable ecommerce order system must assume:

  • The browser will resend requests (refresh, back/forward, double click, retry on 502).
  • Your frontend will retry (React Query, Axios interceptors).
  • Payment providers will retry (Stripe, PayPal webhooks redelivery).
  • Your own workers will retry (BullMQ / cron / lambda timeouts).

So the question isn’t “how do I prevent retries?”.

The question is: how do I make retries safe.

That’s what idempotency is.


Step 1, Define your “one order” identity

Before code, define what “the same order” means in your system.

Common stable identifiers:

  • checkoutSessionId (Stripe Checkout)
  • paymentIntentId (Stripe PaymentIntent)
  • paypalOrderId (PayPal)
  • Your own cartId + userId + attemptNumber (only if you control the full flow)

My rule: use the payment provider’s unique object ID whenever possible, because that’s what finance and support will reference.

Minimal MongoDB model (practical)

// orders collection
{
  _id: ObjectId,
  userId: ObjectId,
  status: "pending" | "paid" | "failed" | "refunded" | "cancelled",

  // Idempotency / payment correlation
  provider: "stripe" | "paypal",
  paymentIntentId: "pi_...",       // or paypalOrderId
  checkoutSessionId: "cs_...",      // optional
  idempotencyKey: "ord_create_...", // your internal key

  // Amounts
  currency: "usd",
  subtotal: 12000,
  tax: 0,
  shipping: 1500,
  discount: 0,
  total: 13500,

  // Snapshot
  items: [{ sku, productId, variantId, qty, unitPrice }],
  shippingAddress: {...},

  createdAt: Date,
  updatedAt: Date
}

Make duplicates impossible with unique indexes

In MongoDB, unique indexes are your last line of defense:

// Ensure only one order per PaymentIntent
await db
  .collection('orders')
  .createIndex(
    { provider: 1, paymentIntentId: 1 },
    { unique: true, partialFilterExpression: { paymentIntentId: { $type: 'string' } } }
  );

// Ensure only one order per internal idempotency key
await db
  .collection('orders')
  .createIndex(
    { idempotencyKey: 1 },
    { unique: true, partialFilterExpression: { idempotencyKey: { $type: 'string' } } }
  );

Partial filters prevent the “multiple nulls” headache.

If you do only one thing from this article: add the unique indexes.


Step 2, Use idempotency keys in your API (the correct way)

Idempotency means: the client can safely repeat the same request, and the server returns the same result without creating duplicates.

What not to do

  • Don’t depend on frontend “disable button” logic.
  • Don’t rely on if (alreadyCreated) return; without DB enforcement.
  • Don’t use timestamps as idempotency keys.

What to do

Pattern: POST /api/orders/confirm

The frontend calls this after payment is completed (or after redirect back from Stripe Checkout).

It must send a stable idempotency key.

Example request body:

{
  "provider": "stripe",
  "paymentIntentId": "pi_3...",
  "idempotencyKey": "confirm_pi_3..."
}

Node/Express handler (MongoDB)

Key idea: upsert + unique index.

import crypto from 'crypto';

function hashPayload(payload) {
  return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}

export async function confirmOrder(req, res) {
  const { provider, paymentIntentId, idempotencyKey } = req.body;
  const userId = req.user._id;

  // Validate inputs (missing IDs = duplicates later)
  if (!provider || !paymentIntentId || !idempotencyKey) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  // Optional: bind the idempotency key to a request fingerprint
  const fingerprint = hashPayload({ userId, provider, paymentIntentId });

  try {
    const now = new Date();

    const result = await req.db.collection('orders').findOneAndUpdate(
      { idempotencyKey },
      {
        $setOnInsert: {
          userId,
          provider,
          paymentIntentId,
          idempotencyKey,
          status: 'pending',
          createdAt: now,
        },
        $set: {
          updatedAt: now,
          requestFingerprint: fingerprint,
        },
      },
      { upsert: true, returnDocument: 'after' }
    );

    const order = result.value;

    // At this point:
    // - first call created the order
    // - duplicate calls return the same order

    return res.json({ orderId: order._id.toString(), status: order.status });
  } catch (err) {
    // If a different request tried to reuse the same idempotency key, you want to detect it
    if (String(err?.code) === '11000') {
      const existing = await req.db.collection('orders').findOne({ idempotencyKey });
      return res.json({ orderId: existing._id.toString(), status: existing.status });
    }

    throw err;
  }
}

Why this works:

  • Upsert makes the “create if missing” atomic.
  • Unique index makes “two creates at the same time” safe.
  • Returning the existing order makes the client retry safe.

Step 3, Webhooks: accept duplicates, design for them

Stripe (and most providers) deliver webhooks at least once. That means the same event can arrive multiple times, in any order.

The two webhook rules I enforce

  1. Verify signatures (security)
  2. Make processing idempotent (reliability)

Stripe webhook verification (Express)

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function stripeWebhook(req, res) {
  const sig = req.headers['stripe-signature'];

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody, // IMPORTANT: raw body, not parsed JSON
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (e) {
    return res.status(400).send(`Webhook Error: ${e.message}`);
  }

  // Persist the event first (idempotent storage)
  const eventId = event.id;
  try {
    await req.db.collection('webhook_events').insertOne({
      provider: 'stripe',
      eventId,
      type: event.type,
      createdAt: new Date(),
      payload: event,
    });
  } catch (e) {
    // Duplicate event (unique index on eventId) => safe to return 200
    if (String(e?.code) === '11000') return res.json({ received: true, duplicate: true });
    throw e;
  }

  // Enqueue processing (don’t do heavy work inline)
  await req.queue.add('stripe-event', { eventId });

  return res.json({ received: true });
}

Create this unique index:

await db.collection('webhook_events').createIndex({ provider: 1, eventId: 1 }, { unique: true });

Now your webhook endpoint is:

  • fast (no timeouts)
  • safe (duplicates are fine)
  • observable (you stored the raw event)

Step 4, Worker: process webhooks with idempotent transitions

In the worker, you translate webhook events into state transitions on the order.

Example transitions (simplified)

  • payment_intent.succeeded → order becomes paid
  • payment_intent.payment_failed → order becomes failed
  • charge.refunded → order becomes refunded

Critical rule: state transitions must be monotonic or at least consistent.

A good pattern is to store processed event IDs:

// In orders document
processedEventIds: ['evt_...'];

And ensure you never apply the same event twice:

export async function processStripeEvent({ eventId }, { db, stripe }) {
  const evt = await db.collection('webhook_events').findOne({ provider: 'stripe', eventId });
  if (!evt) return;

  const event = evt.payload;

  if (event.type === 'payment_intent.succeeded') {
    const pi = event.data.object;

    // Find order by paymentIntentId, upsert if you support “webhook-first” flows
    const query = { provider: 'stripe', paymentIntentId: pi.id };

    const update = {
      $set: { status: 'paid', paidAt: new Date(), updatedAt: new Date() },
      $addToSet: { processedEventIds: eventId },
    };

    // Only update if we haven't processed this event
    const result = await db
      .collection('orders')
      .updateOne({ ...query, processedEventIds: { $ne: eventId } }, update, { upsert: true });

    return result.modifiedCount;
  }
}

This is idempotent because:

  • same eventId won’t apply twice
  • order uniqueness is enforced by the paymentIntentId unique index

Step 5, Retries: queue side effects, never do them inline

Duplicate orders often happen when you do too much work in one request:

  • create order
  • decrement stock
  • create shipment
  • send email
  • call ERP

…and then the request times out at step 4. The client retries, and now step 1 runs again.

The fix

  1. Confirm payment + create/lock the order record (idempotent)
  2. Enqueue side effects
  3. Process side effects in workers with retries

BullMQ example:

import { Queue, Worker } from 'bullmq';

export const orderQueue = new Queue('orders', { connection: redis });

// After marking order paid
await orderQueue.add(
  'post-payment',
  { orderId },
  {
    attempts: 8,
    backoff: { type: 'exponential', delay: 2000 },
    removeOnComplete: 1000,
    removeOnFail: 5000,
  }
);

new Worker(
  'orders',
  async (job) => {
    if (job.name === 'post-payment') {
      const { orderId } = job.data;

      // Make each side effect idempotent too
      await sendReceiptEmailOnce(orderId);
      await reserveOrDecrementInventoryOnce(orderId);
      await createShipmentLabelOnce(orderId);
    }
  },
  { connection: redis, concurrency: 10 }
);

“Once” functions: how to implement them

Use a small collection to track side effect completion:

// order_side_effects
{
  orderId: ObjectId,
  key: "receipt_email" | "inventory_decrement" | "shipping_label",
  doneAt: Date
}

Unique index:

await db.collection('order_side_effects').createIndex({ orderId: 1, key: 1 }, { unique: true });

Then:

async function runOnce(db, orderId, key, fn) {
  try {
    await db.collection('order_side_effects').insertOne({ orderId, key, doneAt: new Date() });
  } catch (e) {
    if (String(e?.code) === '11000') return; // already done
    throw e;
  }

  await fn();
}

export async function sendReceiptEmailOnce(orderId) {
  return runOnce(db, orderId, 'receipt_email', async () => {
    // send email
  });
}

Now retries are safe.


Step 6, The production-grade checklist (what I install)

Here’s the exact checklist I use when stabilizing a revenue-critical store.

A) API idempotency

  • Every create/confirm endpoint accepts Idempotency-Key (header) or idempotencyKey (body)
  • Server stores idempotency key with a unique index
  • Server returns existing order on duplicate key
  • Idempotency keys expire (optional) or are scoped per order/payment object

B) Database constraints

  • Unique index on payment provider object ID (e.g., paymentIntentId)
  • Unique index on internal idempotency key
  • Unique index on webhook event IDs
  • Optional: unique on clientOrderRef if you integrate with ERP

C) Webhook safety

  • Raw-body signature verification
  • Persist webhook events (audit trail)
  • Enqueue processing to a worker
  • Worker is idempotent (processed events tracked)

D) Side effects

  • All side effects run in workers (BullMQ)
  • Each side effect has a run once guard (unique index)
  • Retries are exponential with enough attempts

E) Observability

  • Every order has: createdBy, source, requestId, idempotencyKey
  • Logs include: orderId + paymentIntentId + eventId
  • Dashboard: webhook failures, queue failures, duplicate prevention count

Instrumentation: the metrics that catch duplicates early

If you’re running a store, you don’t want to “discover” duplicates from angry emails.

I track these counters:

  • orders.create.conflict (Mongo duplicate key hit)
  • webhooks.duplicate_event (event insert duplicate)
  • orders.confirm.replayed (same idempotency key reused)
  • queue.job.retried and queue.job.failed

If you use Sentry + a log aggregator:

  • add tags: orderId, paymentIntentId, eventId, idempotencyKey
  • alert when orders.create.conflict spikes (could be a frontend loop)

Common edge cases (real-world)

1) “Webhook-first” vs “client-first” ordering

Sometimes the webhook arrives before the user returns to your site.

Two safe approaches:

  • Webhook-first: webhook worker upserts the order and marks it paid
  • Client-first: client confirms order (creates pending), webhook updates to paid

Both work if you have unique indexes on paymentIntentId and idempotent updates.

2) Partial payments / delayed capture

If you authorize now and capture later:

  • store authorized vs captured statuses
  • treat capture as its own idempotent transition

3) Refunds

Refund events can come multiple times.

  • store refund IDs
  • use $addToSet with refund objects keyed by refundId

4) Inventory decrement timing

My default:

  • Reserve on checkout start (soft hold, expires)
  • Decrement on payment success (hard decrement)

Both actions must be idempotent.


A sane end-to-end flow (recommended)

  1. User initiates checkout → create PaymentIntent/CheckoutSession
  2. User pays
  3. Stripe webhook payment_intent.succeeded arrives
  4. Webhook endpoint stores event + queues processing
  5. Worker upserts order by paymentIntentId, marks paid
  6. Worker queues post-payment side effects
  7. Side-effect workers run exactly-once guards

Notice what’s missing: no single HTTP request is responsible for everything.

That’s how you make retries safe.


If you want, I can harden this in your store

If your current system has duplicate orders, random webhook failures, or “sometimes payment succeeds but order doesn’t show,” that’s usually a 1–3 day stabilization sprint for me:

  • Add unique indexes + idempotency keys
  • Make webhooks production-safe
  • Move side effects to queues
  • Add the minimum observability so issues are diagnosable fast

If you want, send me your current flow (Stripe/PayPal, MERN/Next.js) and I’ll tell you exactly where duplicates can slip in—and the fastest path to a durable fix.

You can find me on different platforms