Modern E-Commerce Checkout Flow Architecture With Payments
A reference architecture for checkout: server-side pricing, stock reservations, payment intents, idempotency, webhooks, the outbox pattern and failure recovery.
Most of an online store can be slow, stale or briefly broken without anyone losing money. Checkout is the exception. It is the one flow where a retry can charge a customer twice, where a race between two shoppers can sell the same last item twice, and where a crash at the wrong moment can take someone’s money without creating an order. Getting it right is less about picking services and more about deciding, up front, what must never happen and designing every step around that.
This article walks through a reference checkout architecture from the moment a shopper presses “Checkout” to the moment the warehouse starts packing. It covers the services involved, the state machine that ties them together, how payments actually flow through a payment service provider (PSP), and the failure cases that separate a demo from a system you can trust with real cards. The examples use Stripe’s API because it is widely documented, but the patterns apply to Adyen, Braintree, Checkout.com and the rest.
What a checkout must guarantee
Before drawing boxes, write down the invariants. These are the rules the system must keep even when networks drop, processes crash and customers double-click.
- Charge at most once per order. Retries, double submits and replayed webhooks must never create a second charge.
- Never keep money without an order. If a payment succeeds, an order must eventually exist, or the money must be refunded.
- Charge the price the customer agreed to. The amount is computed on the server and frozen into a quote; the browser never tells you what something costs.
- Don’t oversell, or decide explicitly how you handle it. Two shoppers racing for the last unit must not both get a confirmed order unless backorders are a business choice.
- Keep card data out of your systems. Raw card numbers should touch only the PSP, which keeps your PCI DSS scope small.
- Degrade, don’t corrupt. If the PSP, the tax service or the email provider is down, checkout can fail or slow down, but it must not leave half-finished state behind.
Every design choice below traces back to one of these.
The reference architecture
Here is the shape of the system. Synchronous calls are solid lines; asynchronous events are dotted.
flowchart LR
C["Browser or app"] --> E["CDN and WAF"]
E --> G["API gateway"]
G --> CO["Checkout service"]
CO --> CA["Cart"]
CO --> PR["Pricing, tax, promotions"]
CO --> INV["Inventory"]
CO --> PAY["Payment service"]
PAY --> PSP["Payment provider"]
C -.->|"card details"| PSP
PSP -.->|"webhooks"| PAY
CO --> ORD["Order service"]
ORD --> DB[("Orders DB and outbox")]
DB -.-> BR["Message broker"]
BR -.-> N["Notifications"]
BR -.-> F["Fulfillment"]
BR -.-> AN["Analytics"]
| Component | Responsibility | Owns data |
|---|---|---|
| CDN and WAF | TLS, bot and abuse filtering, rate limits at the edge | No |
| API gateway | Authentication, request size limits, routing | No |
| Checkout service | Runs the checkout state machine and calls everything else | Checkout sessions |
| Cart | What the shopper wants to buy | Carts |
| Pricing | Prices, discounts, tax, shipping; issues signed quotes | Price books, promotions |
| Inventory | Stock levels and reservations | Stock, reservations |
| Payment service | The only component that talks to the PSP | Payment records, idempotency keys |
| Order service | Creates the order of record and publishes events | Orders, outbox |
| Broker consumers | Email, fulfillment, analytics | Their own stores |
Two things about this picture matter more than the box names.
First, the checkout service is an orchestrator. It holds the state of each checkout and decides what happens next. You could instead let services react to each other’s events (choreography), but checkout has a strict order of steps and several compensating actions, and that is much easier to reason about, debug and monitor when one component owns the sequence.
Second, the card details arrow skips your backend. The browser sends card data straight to the PSP through the PSP’s hosted fields or payment page, and your systems only ever see a token or a payment ID. That single decision removes most of the PCI DSS burden, which we come back to below.
Checkout as a state machine
A checkout is long-lived. It can sit waiting for a 3-D Secure challenge, a slow bank or a webhook for minutes. Model it explicitly as a state machine stored in a database, not as a chain of function calls that only exists in memory.
stateDiagram-v2
[*] --> Open
Open --> Quoted: price locked
Quoted --> Reserved: stock held
Reserved --> AwaitingPayment: payment created
AwaitingPayment --> AwaitingPayment: 3DS challenge
AwaitingPayment --> Authorized: PSP confirms
AwaitingPayment --> Failed: declined
Reserved --> Expired: hold timed out
Authorized --> Confirmed: order written
Confirmed --> Captured: shipped
Failed --> [*]
Expired --> [*]
Captured --> [*]
Each transition is a single conditional update, so two workers can’t move the same checkout in different directions:
UPDATE checkout_sessions
SET state = 'AUTHORIZED', payment_id = :payment_id, updated_at = now()
WHERE id = :checkout_id
AND state = 'AWAITING_PAYMENT';
-- 0 rows updated means another worker already moved it: stop, don't retry blindly.
Storing the state gives you three things for free: a sweeper job can find sessions stuck in one state for too long, support staff can see exactly where a customer’s checkout stopped, and every retry starts from a known point instead of from the beginning.
Pricing and the quote
The browser shows prices, but it never decides them. When the shopper reaches the payment step, the checkout service asks pricing for a quote: the line items, unit prices, discounts, shipping, tax and the total, all computed on the server.
- Freeze it. Store the quote with an ID and an expiry (15 to 30 minutes is common). The payment amount comes from the stored quote, never from a request field.
- Use integer minor units. Store
1999cents with a currency code, not19.99as a float. PSP APIs work the same way: Stripe’samountis an integer in the smallest currency unit. - Recompute on change. If the cart, shipping address or coupon changes, the old quote is void. Tax in particular depends on the destination, so an address change must trigger a new quote.
- Say what changed. If a price moved between cart and checkout, show the new total and ask the shopper to confirm. Silently charging more breaks invariant 3.
Reserving inventory
Overselling is a business decision as much as a technical one. A store selling print-on-demand T-shirts can’t oversell; a store selling a limited sneaker drop absolutely can. The common strategies:
| Strategy | When stock is held | Pros | Cons |
|---|---|---|---|
| Reserve at add-to-cart | When the item enters the cart | Shopper never loses an item mid-checkout | Abandoned carts lock up stock; easy to abuse |
| Reserve at checkout with a TTL | When the payment step starts | Short holds, little locked stock | Needs expiry handling and a sweeper |
| Decrement after payment | When the PSP confirms | Simplest; nothing to release | Two shoppers can pay for the last unit; needs refunds |
| Allow oversell | Never blocks | Maximum conversion | Needs backorder and customer-service process |
Reserving at checkout with a time limit is the usual default. The reservation must be atomic, which in a relational database is a conditional update:
-- Reserve 2 units of a SKU only if they are actually available.
UPDATE inventory
SET reserved = reserved + 2
WHERE sku = 'TSHIRT-BLK-M'
AND on_hand - reserved >= 2;
-- 1 row: reserved. 0 rows: not enough stock, tell the shopper now.
INSERT INTO reservations (id, checkout_id, sku, qty, expires_at)
VALUES (:id, :checkout_id, 'TSHIRT-BLK-M', 2, now() + interval '15 minutes');
Run both statements in one transaction. A sweeper releases expired reservations, and the release must also be conditional (only if the reservation is still active), because the payment might confirm at the same moment the timer fires.
A Redis key with a TTL is tempting for this, and it works well as a fast pre-check, but keep the source of truth in a database that survives restarts and supports transactions. For extreme hot spots, such as a single SKU in a flash sale, put a queue or a token bucket in front of that SKU rather than letting thousands of requests fight over one row.
Where should the amount sent to the payment provider come from?
Correct! The amount comes from a frozen, server-computed quote, so the customer is charged exactly what they confirmed and the browser can't change it.
How the payment actually flows
Modern card payments are not a single “charge this card” call. They are a small protocol between your server, the browser and the PSP. With Stripe it is the PaymentIntent; Adyen calls it a payment session; the structure is the same.
sequenceDiagram
participant B as Browser
participant CO as Checkout service
participant P as Payment service
participant S as PSP
B->>CO: Pay for quote Q
CO->>P: Create payment (quote Q, key K)
P->>S: Create intent, Idempotency-Key K
S-->>P: intent id, client secret
P-->>B: client secret
B->>S: Card details via hosted fields
S-->>B: 3DS challenge if required
B->>S: Challenge completed
S-->>P: Webhook: payment authorized
P->>CO: Payment authorized
CO->>CO: Confirm order
B->>CO: Poll checkout status
CO-->>B: Order confirmed
The steps that matter:
- Create the intent on the server, with the amount from the quote and an idempotency key derived from the checkout (more on that below).
- Return only the client secret to the browser. It lets the browser complete this one payment and nothing else.
- Collect card details in the PSP’s UI. Hosted fields or an embedded payment element render inside an iframe from the PSP’s domain, so card numbers never pass through your servers or your JavaScript.
- Let the PSP run Strong Customer Authentication. In the EEA and the UK, PSD2 requires strong customer authentication for many online card payments, which usually means a 3-D Secure challenge in the bank’s app. The intent sits in a “requires action” state until the shopper completes it.
- Trust the webhook, not the redirect. The browser coming back to your “thank you” page proves nothing; the shopper may have closed the tab, or someone may have typed the URL. The authoritative signal is the PSP’s signed webhook, or a server-side fetch of the intent’s status.
Here is step 1 in Python with Stripe’s library:
import stripe
stripe.api_key = load_secret("stripe-secret-key") # from your secret manager, never in code
def create_payment(checkout_id: str, quote) -> stripe.PaymentIntent:
return stripe.PaymentIntent.create(
amount=quote.total_minor, # e.g. 4598 for 45.98
currency=quote.currency, # e.g. "usd"
capture_method="manual", # authorize now, capture at shipment
automatic_payment_methods={"enabled": True},
metadata={"checkout_id": checkout_id, "quote_id": quote.id},
idempotency_key=f"checkout-{checkout_id}-quote-{quote.id}",
)
Authorize now, capture later
Authorization asks the issuing bank to hold the funds; capture actually moves them. For physical goods, the common pattern is to authorize at checkout and capture when the order ships, which avoids refunds for items you turn out not to have. The catch is that authorizations expire. For many online card payments the window is around seven days, but it varies by card network, card type and PSP, so check your provider’s documentation and capture (or re-authorize) before the hold lapses. Digital goods and services delivered immediately usually capture at once.
Keeping PCI DSS scope small
PCI DSS applies to anyone who stores, processes or transmits cardholder data. How much of it applies to you depends on how card data reaches the PSP:
| Integration | Card data touches your systems? | Typical self-assessment |
|---|---|---|
| Redirect to a PSP-hosted payment page | No | SAQ A |
| PSP iframe or hosted fields on your page | No, but your page hosts the iframe | SAQ A |
| Your JavaScript posts card data directly to the PSP | Passes through your page’s code | SAQ A-EP |
| Card numbers reach your servers | Yes | SAQ D, the full standard |
The exact eligibility rules change between versions of the standard (PCI DSS v4.x added specific requirements for scripts running on payment pages), so confirm your level with your PSP and acquirer. The architectural point stands regardless: keep raw card numbers out of your network, and a whole class of breaches and audits stops applying to you. Pair it with a strict Content Security Policy on the checkout page so that only your own scripts and the PSP’s can run there.
A shopper is redirected back to your order confirmation URL after paying. What should mark the order as paid?
Correct! Redirects can be skipped, replayed or forged. Only a signed webhook or a direct server-to-PSP status check is authoritative.
Idempotency: charging at most once
Networks fail in the worst possible place: after the PSP has charged the card but before your server hears back. Your code sees a timeout and, naturally, wants to retry. Without protection, that retry is a second charge.
An idempotency key fixes this. You send a unique key with the create request; if the PSP has already seen that key, it returns the original result instead of doing the work again. Stripe accepts an Idempotency-Key header on POST requests and keeps keys for at least 24 hours, which comfortably covers retries.
How you choose the key matters:
- Derive it from business identity, such as the checkout ID plus the quote ID. A random UUID generated per request defeats the purpose, because every retry gets a fresh key.
- Change it when the intent should change. If the shopper edits the cart and gets a new quote, the new quote ID produces a new key, and the PSP correctly creates a new payment.
- Apply the same idea inside your system. The browser should send its own key with the “Pay” request, so a double-click or a mobile app retry maps to the same checkout operation. Store the key and the response, and return the stored response on a repeat.
The hardest case is the unknown outcome: the call timed out, and you don’t know whether the payment exists. Never mark it failed. Retry with the same key, or look the payment up by your metadata, and only then decide. Treat “timeout” as “pending” everywhere in the checkout state machine.
Webhooks done properly
Webhooks are how the PSP tells you what happened, and they come with their own rules. Stripe, for example, delivers events at least once, does not guarantee ordering, and retries failed deliveries with backoff for up to three days in live mode. Your handler has to cope with all of that:
from flask import Flask, request
import stripe
app = Flask(__name__)
ENDPOINT_SECRET = load_secret("stripe-webhook-secret")
@app.post("/webhooks/stripe")
def stripe_webhook():
payload = request.get_data()
signature = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(payload, signature, ENDPOINT_SECRET)
except (ValueError, stripe.SignatureVerificationError):
return "", 400 # not from Stripe, or tampered with
# Record the event id first; a duplicate delivery becomes a no-op.
if not inbox.insert_if_new(event.id, event.type, payload):
return "", 200
queue.enqueue("process_payment_event", event.id) # do the real work async
return "", 200
- Verify the signature against the raw request body. Parsing and re-serializing the JSON first will break verification.
- Deduplicate by event ID in an inbox table, so replays are harmless.
- Acknowledge fast, process async. Return 2xx within a few seconds and hand the event to a worker. Slow handlers cause timeouts, which cause retries, which cause load.
- Don’t trust event order. A worker should fetch the current state of the payment from the PSP, or compare against your own state machine, rather than applying events blindly in arrival order.
- Reconcile anyway. A scheduled job should look for checkouts stuck in
AWAITING_PAYMENTand ask the PSP directly. Webhook endpoints get misconfigured, secrets get rotated and deploys go wrong; reconciliation catches what webhooks miss. A daily match of your orders against the PSP’s settlement report catches the rest.
From payment to order: the outbox
Once the payment is authorized, the order service writes the order and tells the rest of the business. Doing that as “insert into the database, then publish to Kafka” is the classic dual write problem: if the process dies between the two steps, you have an order nobody ships, or an event for an order that doesn’t exist.
The transactional outbox removes the gap. Write the order and the event in the same database transaction, and let a separate relay publish events from the outbox table to the broker.
BEGIN;
INSERT INTO orders (id, checkout_id, customer_id, total_minor, currency, status)
VALUES (:order_id, :checkout_id, :customer_id, 4598, 'USD', 'CONFIRMED');
INSERT INTO outbox (id, aggregate_id, event_type, payload)
VALUES (:event_id, :order_id, 'OrderConfirmed', :payload_json);
COMMIT;
The relay (a polling worker, or change data capture such as Debezium) publishes each outbox row and marks it sent. It can publish the same row twice after a crash, so delivery is at least once, and every consumer must be idempotent: the email service records which order confirmations it has sent, fulfillment ignores an order ID it already has, and analytics deduplicates by event ID.
Notice what is not on the critical path: email, fulfillment and analytics. If the email provider is down, the shopper still gets a confirmed order on screen, and the email goes out when the provider recovers.
Why write the OrderConfirmed event to an outbox table instead of publishing it straight to the broker?
Correct! The outbox makes the order and the event a single transaction. Delivery is still at least once, so consumers must be idempotent.
When things go wrong: compensations
Checkout is a saga: a sequence of local steps, each with a compensating action if a later step fails. Write the table down and test every row.
| Failure | Where it happens | Compensation |
|---|---|---|
| Out of stock at reservation | Before payment | Tell the shopper immediately; nothing to undo |
| Card declined | Payment | Release the reservation; let the shopper try another method |
| 3DS abandoned | Payment | Leave the intent to expire; sweeper releases stock when the hold ends |
| PSP timeout | Payment | Treat as pending; retry with the same key or query the PSP |
| Authorized, but the reservation already expired | Between payment and order | Try to re-reserve; if stock is gone, cancel the authorization and apologize |
| Authorized, but the order write fails | Order service | Retry from the webhook or sweeper; if it can never succeed, void or refund |
| Capture fails at shipment | Fulfillment | Hold the shipment; retry, or ask the customer for a new payment method |
Some practical rules make these compensations reliable:
- Every compensation is itself idempotent. Releasing a reservation twice or cancelling an authorization twice must be safe.
- Prefer voiding an authorization to refunding a capture. A void releases the hold without moving money; a refund moves it back and costs you fees.
- Wrap PSP calls in timeouts and a circuit breaker. When the provider is degraded, fail fast and show a clear “try again in a minute” message instead of letting threads pile up. If you run a second PSP, the breaker is where you route to it, but remember that saved cards are usually tokenized per provider, so failover works best for new card entries.
Running it on AWS, Google Cloud or Azure
None of this requires a particular cloud. Each provider has a managed service for every layer:
| Layer | AWS | Google Cloud | Azure |
|---|---|---|---|
| Edge and WAF | CloudFront, AWS WAF | Cloud CDN, Cloud Armor | Front Door with WAF |
| Services | ECS, EKS or Lambda | Cloud Run or GKE | Container Apps or AKS |
| Relational data | Aurora or RDS for PostgreSQL | Cloud SQL or AlloyDB | Azure Database for PostgreSQL |
| Cache | ElastiCache | Memorystore | Azure Managed Redis |
| Events | SNS and SQS, or EventBridge | Pub/Sub | Service Bus or Event Grid |
| Workflow engine | Step Functions | Workflows | Durable Functions |
| Secrets | Secrets Manager | Secret Manager | Key Vault |
A few choices hold across all three:
- Use a relational database for checkout state, orders and inventory. You need conditional updates and multi-row transactions; this is the part of the store where a document store’s flexibility buys you nothing.
- A workflow engine is optional. Step Functions, Workflows or Durable Functions can host the state machine for you, with retries and timeouts built in. A table plus a sweeper is just as valid and easier to test locally.
- Give the payment service its own identity and secrets. Only it should be able to read the PSP API key. The same least-privilege thinking from AWS IAM and GCP IAM applies: one service, one identity, only the permissions it needs.
- Deploy across zones, not necessarily regions. A multi-zone database and stateless services in at least two zones cover most failures. Multi-region checkout is a large step up in complexity, because inventory and payments must stay consistent across regions; see Choosing a Cloud Provider: One Cloud or Several before taking it on.
Observability and testing
You can’t fix what you can’t see, and checkout failures are often silent: the shopper just leaves.
- Trace every checkout end to end. Put the checkout ID in every log line and pass a trace context (OpenTelemetry is the common choice) through every call, including into PSP metadata, so one ID finds the whole story.
- Watch business metrics, not only error rates. Authorization rate by card brand and country, decline codes, time spent in
AWAITING_PAYMENT, webhook delivery lag, expired reservations and the number of checkouts the sweeper had to rescue. A drop in authorization rate is often the first sign of a PSP or bank problem. - Test with the PSP’s sandbox. Stripe and other providers publish test card numbers that force declines, 3DS challenges and specific errors. Script a test for each row of the compensation table.
- Inject the failures you designed for. Kill the order service between authorization and order write, delay webhooks, replay them twice, time out the PSP call. If the invariants hold, you’re done; if not, you found the bug before a customer did.
- Load test the hot path. Flash sales concentrate traffic on a few SKUs. Test the inventory row contention specifically, not just aggregate requests per second.
Key takeaways
- Start from invariants: charge at most once, never keep money without an order, charge the quoted price, handle overselling deliberately and keep card data out of your systems.
- Model checkout as a persisted state machine owned by one orchestrating service, with conditional updates for every transition.
- Price on the server, freeze the result in an expiring quote, and store money as integer minor units.
- Reserve stock atomically with a time limit, and release expired holds with a conditional, idempotent sweeper.
- Let the PSP collect card details through hosted fields or a payment page, and treat its signed webhook or a server-side status check as the source of truth.
- Use idempotency keys derived from business identity, and treat timeouts as “pending”, never “failed”.
- Write orders and events together with a transactional outbox, and make every consumer idempotent.
- Reconcile against the PSP on a schedule; webhooks alone will eventually miss something.
For the official details, see Stripe’s documentation on idempotent requests, webhooks and placing a hold on a payment method, and the PCI Security Standards Council document library for the current SAQ eligibility rules.