3 MIN READ

The Medusa Checkout Flow: Every Step, and Where It Breaks

Address, shipping, payment session, complete. The four stages of a Medusa checkout, the state machine underneath, and the failure modes that cost orders.

BY ANANYA IYERUPDATED
Illustration for “The Medusa Checkout Flow: Every Step, and Where It Breaks” — Checkout

Medusa's checkout is a sequence of cart updates ending in one irreversible call. Understanding the sequence — and what state the cart must be in before each step — removes most of the confusion, because the errors are almost always "you skipped a prerequisite" rather than anything subtle.

The sequence

StageCallPrerequisite
1. Address`cart.update({ shipping_address, email })`Cart exists
2. Shipping`cart.addShippingMethod({ option_id })`Address set
3. Payment`payment.initiatePaymentSession(cart, { provider_id })`Shipping method set
4. Complete`cart.complete(cartId)`Payment session authorised

Skipping ahead does not work — shipping options are computed from the destination, and payment sessions are created for a total that includes shipping.

Stage 1: address and email

lib/data/checkout.tsts
"use server"

export async function setAddresses(cartId: string, form: AddressForm) {
  await sdk.store.cart.update(cartId, {
    email: form.email,
    shipping_address: {
      first_name: form.firstName,
      last_name: form.lastName,
      address_1: form.address1,
      city: form.city,
      postal_code: form.postalCode,
      country_code: form.countryCode.toLowerCase(),
      phone: form.phone,
    },
    billing_address: form.billingSameAsShipping
      ? undefined
      : mapBilling(form),
  })

  revalidateTag("cart")
}

Capture email first, before anything else on the page. It is what makes abandoned cart recovery possible, and a customer who drops out at shipping is worth recovering.

country_code must be lowercase ISO 3166-1 alpha-2 and must belong to the cart's region. A mismatch produces empty shipping options at the next step, which reads as a shipping configuration bug and is not.

Stage 2: shipping

ts
export async function getShippingOptions(cartId: string) {
  const { shipping_options } = await sdk.store.fulfillment.listCartOptions({ cart_id: cartId })
  return shipping_options
}

export async function setShippingMethod(cartId: string, optionId: string) {
  await sdk.store.cart.addShippingMethod(cartId, { option_id: optionId })
  revalidateTag("cart")
}

If the list comes back empty, work through it in this order: does a stock location exist, is it linked to a fulfilment set with a service zone covering the destination country, and does the shipping option's price rule cover the cart? Nine times out of ten it is the service zone. Fulfilment providers covers the model.

Some options are calculated — the price comes from a carrier API rather than a fixed amount — so render prices from the returned option rather than caching them.

Stage 3: payment session

ts
export async function initPayment(cart: Cart, providerId = "pp_stripe_stripe") {
  const collection =
    cart.payment_collection ??
    (await sdk.store.payment.initiatePaymentSession(cart, { provider_id: providerId }))

  return collection
}

This creates a payment collection for the cart total and a session with the provider. For Stripe the session carries a client_secret that the client-side Stripe Elements uses to confirm. Details in the Stripe integration guide.

Two rules. Initiate the session after the total is final — adding a promotion or changing shipping afterwards invalidates it, and you must refresh. And never trust a client-reported amount; the server owns the total.

Stage 4: completion

ts
export async function placeOrder(cartId: string) {
  const result = await sdk.store.cart.complete(cartId)

  if (result.type === "order") {
    ;(await cookies()).delete("_medusa_cart_id")
    redirect(`/order/confirmed/${result.order.id}`)
  }

  // type === "cart": completion was rejected; the cart comes back with the reason.
  return { error: result.error?.message ?? "We could not complete your order." }
}

Completion captures or authorises payment, reserves inventory and creates the order. It is idempotent per cart — a double submit does not create two orders — but you should still disable the button on submit, because the second click leaves a customer staring at a spinner.

Delete the cart cookie only on success.

Where it breaks

Empty shipping options. Service zone does not cover the country. Check the fulfilment set first.

Stale payment session. Total changed after initiation. Refresh the session whenever the cart total changes.

Completion 400s. Usually inventory that went while the customer was typing. Re-fetch the cart and show which line failed.

Region and country mismatch. The address country must be in the cart's region. Constrain the country selector to the region's countries rather than validating after the fact.

Losing the cart at login. Transfer it. See cart implementation.

Customising the flow

Because completion runs as a workflow, you can hook custom logic into it — fraud checks, B2B approvals, per-jurisdiction restrictions — as steps with compensation, so a rejection leaves no partial order behind.

That is the structural argument for Medusa over a hosted checkout: the rules live in your codebase, tested and reviewed, rather than in a script tag.

Practical guidance

One page or four, but keep the state on the cart. The cart is the source of truth; do not mirror checkout state in client memory.

Show real errors. "Only 2 left in stock" converts better than "Something went wrong."

Instrument each stage. Step-level drop-off tells you which stage is the problem; a single funnel number does not.

Test with a real card in production before launch. Test mode does not exercise your live provider configuration.

One page or several?

Both work. The evidence is less decisive than either camp claims, and the deciding factor is usually your customers rather than the pattern.

Single pageMulti step
Perceived effortLower for short formsLower for long forms
Error handlingAll visible at onceContained per step
AnalyticsOne drop-off pointStep-level drop-off
MobileLong scrollClearer progress
Best forFew fields, guest checkoutAddress complexity, B2B

Whichever you pick, keep the cart as the source of truth and persist after each stage. A customer who reloads mid-checkout should not start again — that is the failure that costs orders regardless of layout.

Express checkout

Wallet buttons — Apple Pay, Google Pay, Link — measurably lift mobile conversion, and they change the sequence: the wallet supplies the address, so you set it on the cart after authorisation rather than before.

ts
// Wallet flow: authorise first, then write the address the wallet returned.
const { paymentIntent } = await stripe.confirmPayment({ elements, redirect: "if_required" })

await sdk.store.cart.update(cartId, {
  email: paymentIntent.receipt_email!,
  shipping_address: mapWalletAddress(paymentIntent.shipping!),
})

await sdk.store.cart.complete(cartId)

The trap is shipping cost. A wallet button shown before an address exists cannot know the shipping price, so either restrict express checkout to flat-rate regions or use the wallet's own shipping-selection callback to update the total before authorisation. Showing one total in the wallet and charging another is both a bad experience and, in some jurisdictions, a compliance problem.


Checkout is where money is lost quietly. We review these.

Frequently asked questions

What are the steps in a Medusa checkout?

Set the email and addresses on the cart, choose a shipping method from the options returned for that address, initiate a payment session with a provider, then complete the cart. Each stage depends on the previous one, so the order is fixed.

Why are there no shipping options for my cart?

Usually because no service zone covers the destination country. Check that a stock location exists, that it is linked to a fulfilment set whose service zone includes the country, and that the shipping option's price rules match the cart.

What does cart.complete() return?

Either an order, when completion succeeds, or the cart with an error when it does not. Branch on `result.type` — assuming an order and reading properties off it is how a failed checkout becomes a crashed page.

When should I create the payment session?

After the cart total is final, which means after shipping and any promotions. If the total changes afterwards the session is stale and must be refreshed before completion.

Can I customise the Medusa checkout flow?

Yes. Completion runs as a workflow, so you can add steps for fraud checks, approvals or jurisdiction rules, each with a compensation function so a rejection leaves no partial order.

How do I handle a failed payment at checkout?

Catch the error at completion, keep the cart intact, and show a specific message. The cart remains editable, so the customer can change payment method and retry without rebuilding their basket.

[ Keep reading ]