Introduction
Checkout is where revenue lives. A single transaction failure, race condition, or payment timeout can mean lost orders and frustrated customers. Design an e-commerce checkout: best practices explained means understanding that reliability isn't optional—it's the foundation of every successful transaction. In this guide, we'll walk through the architectural patterns and implementation details that separate production-grade checkouts from fragile ones.
We've built checkout systems across multiple e-commerce platforms and seen firsthand how small oversights cascade into major incidents. A missing inventory reservation can oversell stock. A forgotten webhook can leave payments in limbo. A client-side price calculation can leak margins. This article captures the lessons from those experiences.
The Checkout Flow: A Choreographed Sequence
Think of checkout as a state machine with strict ordering. Each step must complete successfully before the next begins, and certain steps must be reversible if later steps fail.
The canonical flow is:
- Validate cart and prices — Ensure items exist, are in stock, and the client's prices match server truth
- Reserve inventory — Decrement available stock and hold it for this order
- Create order as pending — Write the order record to the database with all items and totals
- Take payment — Call the payment gateway and obtain authorization or capture
- Confirm order and release on failure — Mark order as confirmed if payment succeeds; release inventory if it fails
Why this order? Because inventory is scarce—locking it early prevents overselling. Payments are expensive calls—we do them after we're confident the order is valid. And if a payment fails, we release what we reserved.
In practice, you'll have error states at each step. If validation fails, reject early and never touch inventory. If payment fails after reservation, immediately release the hold. If the final confirmation call times out, use webhooks to verify the actual payment state (more on this below).
Recalculate Everything on the Server
The client sends a cart: three widgets at $29.99 each, subtotal $89.97, tax $7.20, total $97.17. Never use these numbers. Calculate them again server-side from the item IDs and current prices.
Why? Because clients can be compromised, misconfigured, or simply malicious. A developer with browser DevTools can change prices. A network proxy can intercept and modify requests. A bug in your frontend framework can miscalculate tax. The server is the only source of truth.
// BAD: trusting client prices
async function checkout(cartData: CartData) {
const total = cartData.total; // ❌ don't do this
const payment = await gateway.charge(total);
// ...
}
// GOOD: recalculate server-side
async function checkout(cartItemIds: string[]) {
const items = await db.items.find({ id: { $in: cartItemIds } });
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
const tax = calculateTax(subtotal, shippingAddress);
const shipping = calculateShipping(items, shippingAddress);
const total = subtotal + tax + shipping;
// Verify client's cart matches—log discrepancies for fraud detection
if (Math.abs(total - cartData.total) > 0.01) {
logger.warn('Cart total mismatch', { client: cartData.total, server: total });
}
const payment = await gateway.charge(total);
// ...
}Recalculation serves three purposes: correctness (your margins stay safe), security (malicious actors can't cheat), and observability (mismatches flag issues). Log them. If you see a pattern of customer-supplied totals differing from server calculations, you've found a bug or an attack.
Payment State Machine: Legal Transitions Only
A payment isn't just "success" or "failure." It's a progression through states, and not all transitions are valid.
| State | Meaning | Valid next states |
|---|---|---|
| Pending | Order created, awaiting payment processing | Authorized, Failed, Refunded |
| Authorized | Payment approved; funds not yet captured | Captured, Failed, Refunded |
| Captured | Funds charged successfully | Refunded |
| Failed | Payment declined or error occurred | Pending (retry) |
| Refunded | Funds returned to customer | None |
Implement this as an enum or union type. When you receive a payment update (from a webhook, or from polling the gateway), validate that the transition is legal before applying it.
type PaymentState = 'pending' | 'authorized' | 'captured' | 'failed' | 'refunded';
const VALID_TRANSITIONS: Record = {
pending: ['authorized', 'failed', 'refunded'],
authorized: ['captured', 'failed', 'refunded'],
captured: ['refunded'],
failed: ['pending'], // allow retry
refunded: [],
};
function canTransition(from: PaymentState, to: PaymentState): boolean {
return VALID_TRANSITIONS[from].includes(to);
}
async function updatePaymentState(orderId: string, newState: PaymentState) {
const order = await db.orders.findById(orderId);
if (!canTransition(order.paymentState, newState)) {
throw new Error(
`Invalid transition: ${order.paymentState} → ${newState}`
);
}
await db.orders.updateOne({ id: orderId }, { paymentState: newState });
}This prevents subtle bugs where a payment ends up in an impossible state—like being both refunded and captured. It also documents the lifecycle for anyone reading the code.
Webhooks Are the Truth
Your checkout page redirects back from the payment gateway. The user sees "Payment successful!" But that redirect can be lost. Their connection drops. A proxy times out. A browser crashes before the redirect completes. If you rely only on that redirect, you've left money on the table: the payment succeeded, but you never created the order.
Webhooks are the reliable path. Your payment gateway sends you an asynchronous notification of the actual payment outcome. You can retry if your webhook endpoint fails. You can store the webhook and replay it. The redirect is just a convenience for the user; the webhook is what actually matters.
// Redirect handler: tell the user we received something, but don't commit yet
app.get('/checkout/return', (req, res) => {
const sessionId = req.query.session_id;
// Render a "we're confirming your payment" page
// Don't mark the order as confirmed here
res.render('checkout-confirming', { sessionId });
});
// Webhook handler: the authoritative update
app.post('/webhooks/payment', async (req, res) => {
const event = req.body;
// Verify signature to prevent spoofing
if (!verifySignature(event, process.env.WEBHOOK_SECRET)) {
res.status(401).send('Unauthorized');
return;
}
// Idempotency: if we've already processed this event, don't do it again
const processed = await db.webhookEvents.findOne({
gatewayEventId: event.id,
});
if (processed) {
res.status(200).send('OK');
return;
}
// Process the event
if (event.type === 'payment.success') {
const order = await db.orders.findOne({ gatewaySessionId: event.session_id });
if (order) {
await db.orders.updateOne(
{ id: order.id },
{ paymentState: 'captured', confirmedAt: new Date() }
);
await sendConfirmationEmail(order.customer.email, order.id);
}
} else if (event.type === 'payment.failed') {
const order = await db.orders.findOne({ gatewaySessionId: event.session_id });
if (order) {
await db.orders.updateOne(
{ id: order.id },
{ paymentState: 'failed' }
);
// Release the inventory reservation
await releaseInventoryReservation(order.id);
}
}
// Mark this webhook as processed (idempotency key)
await db.webhookEvents.insertOne({
gatewayEventId: event.id,
type: event.type,
processedAt: new Date(),
});
res.status(200).send('OK');
});The key principles: verify the webhook signature so you don't act on spoofed events, store an idempotency key to handle retries gracefully, and treat the webhook as the source of truth for payment state. Also implement a background job that periodically queries the gateway for orders that are stuck in "pending" state—if a webhook went missing, you'll detect it and recover.
Common Mistakes
- Relying on the redirect alone. The user might never see it. Always back it up with webhooks and polling.
- Not releasing inventory on payment failure. You reserved stock, the payment failed, but you never released it. That item stays locked for days or forever. Use a cleanup job as a backup.
- Missing idempotency keys on webhook handling. A network glitch causes your webhook to be delivered twice. You charge the customer twice. Always store the event ID and check if you've seen it before.
- Allowing invalid state transitions. A race condition causes the payment to jump from "pending" to "refunded" without ever being "captured." No validation caught it. Now your records are inconsistent.
- Not logging payment mismatches. Client sends $99.99, server calculates $97.17. You silently charge $97.17. The customer is confused, but you never logged the difference. Always instrument price calculations.
- Creating the order before validating payment. If payment fails, you've already burned an order ID, and now you have to decide whether to retry with the same ID or create a new one. Validate everything first.
Key Takeaways
- Checkout is a state machine with strict ordering: validate, reserve, create, pay, confirm.
- Always recalculate prices and totals server-side; never trust client values.
- Implement payment states as a restricted state machine with legal transitions only.
- Webhooks are the truth; the redirect is a convenience. Always verify webhook signatures and use idempotency keys.
- Release inventory immediately if payment fails. Use background jobs to catch missed webhooks and cleanup orphaned reservations.
- Log price mismatches and state transitions for observability and fraud detection.






Comments
Loading comments…