Blog Post
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:
- Use idempotency keys for every “create order / confirm payment” call.
- Make your database enforce uniqueness (unique indexes) so duplicates are impossible, even if your code is wrong.
- Treat webhooks as at-least-once delivery and make webhook handlers idempotent.
- Never do side effects inline (emails, stock sync, shipping labels). Put them on a queue with retries.
- 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
- Verify signatures (security)
- 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 becomespaidpayment_intent.payment_failed→ order becomesfailedcharge.refunded→ order becomesrefunded
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
eventIdwon’t apply twice - order uniqueness is enforced by the
paymentIntentIdunique 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
- Confirm payment + create/lock the order record (idempotent)
- Enqueue side effects
- 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) oridempotencyKey(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
clientOrderRefif 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 onceguard (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.retriedandqueue.job.failed
If you use Sentry + a log aggregator:
- add tags:
orderId,paymentIntentId,eventId,idempotencyKey - alert when
orders.create.conflictspikes (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
authorizedvscapturedstatuses - treat capture as its own idempotent transition
3) Refunds
Refund events can come multiple times.
- store refund IDs
- use
$addToSetwith 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)
- User initiates checkout → create PaymentIntent/CheckoutSession
- User pays
- Stripe webhook
payment_intent.succeededarrives - Webhook endpoint stores event + queues processing
- Worker upserts order by paymentIntentId, marks paid
- Worker queues post-payment side effects
- 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.