How to build a fully custom checkout flow

Type
How-to
Applies to
Medusa v2.x
Updated

Short answer

To build a fully custom checkout flow, use a commerce backend whose checkout is an API rather than a hosted page. In Medusa, your storefront saves the email and addresses on the cart, sets a shipping method, starts a payment session such as Stripe, and completes the cart. Custom rules run in the completeCartWorkflow validate hook.

Before you start

  • A Medusa application with at least one region, shipping option, and payment provider enabled.
  • A storefront using the JS SDK (Copy to clipboard@medusajs/js-sdk) with a publishable API key.
  • For card payments, the Stripe Module Provider registered in Copy to clipboardmedusa-config.ts.

Medusa's checkout is a set of Store API calls on a cart, not a hosted page. You decide how many screens there are and what each one shows. The docs split it into email, address, shipping, payment, and completion, and say you can combine steps or change their order.

1. Save the email and addresses on the cart

The first step attaches customer details to the cart. One Copy to clipboardsdk.store.cart.update call can set the email, shipping address, and billing address, which makes a one-page checkout possible.

await sdk.store.cart.update(cartId, {
email,
shipping_address: address,
billing_address: address,
})

2. Let the customer pick a shipping option

The shipping step lists the options valid for the cart's address and items, then saves the choice. Call Copy to clipboardsdk.store.fulfillment.listCartOptions({ cart_id }), render the options, and call Copy to clipboardsdk.store.cart.addShippingMethod(cartId, { option_id }). Calculated rates come from Copy to clipboardsdk.store.fulfillment.calculate.

3. Start a payment session and render the payment UI

The payment step creates a payment session with the provider the customer picked. List providers for the cart's region with Copy to clipboardsdk.store.payment.listPaymentProviders, then call Copy to clipboardsdk.store.payment.initiatePaymentSession(cart, { provider_id }). For Stripe, the session's data holds the client secret that Stripe's React components need.

Medusa's Stripe storefront guide uses Stripe's Card Element. To show cards, wallets, and local methods in one component, follow the Payment Element guide for the Next.js Starter Storefront. Apple Pay and Google Pay depend on the Stripe provider's Copy to clipboardautomatic_payment_methods option.

4. Complete the cart

Completion turns the cart into an order. Call Copy to clipboardsdk.store.cart.complete(cartId) after the customer confirms payment in Stripe's UI. A response with Copy to clipboardtype: "order" means an order was placed; Copy to clipboardtype: "cart" means completion failed, and the response's Copy to clipboarderror field explains why. Completion is idempotent per cart, so a retry returns the same order instead of a duplicate.

5. Add your own rules to order placement

The Complete Cart route runs Copy to clipboardcompleteCartWorkflow, and its Copy to clipboardvalidate hook runs before the order is created and the payment is authorized. Throw an error there to block checkout, for example for a minimum order or a restricted address.

// src/workflows/hooks/validate-checkout.ts
import { completeCartWorkflow } from "@medusajs/medusa/core-flows"
import { MedusaError } from "@medusajs/framework/utils"
completeCartWorkflow.hooks.validate(async ({ cart }, { container }) => {
const allowed = true // your rule, using cart
if (!allowed) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Checkout blocked")
}
})

Don't change line items or totals in this hook: the order is built from the cart as it was before the hook ran. For work after the order exists, such as ERP sync or emails, subscribe to the Copy to clipboardorder.placed event. The completeCartWorkflow reference covers payment handling and idempotency.

Checkout patterns you can build with Medusa

Status labels: Native is Medusa core, Starter is the B2B Starter, Official guide is a docs tutorial with example code.

PatternHow it works in MedusaStatus
Multi-step checkoutOne Store API call per step, as in the Next.js Starter StorefrontNative
One-page checkoutSame calls on one screen, steps combinedNative
Express checkout from a product linkNext.js express checkout tutorialOfficial guide
Stripe Payment ElementNext.js Starter Storefront customization guideOfficial guide
Apple Pay and Google PayStripe provider with automatic payment methodsNative
B2B approval before the order is placedApproval workflows in the B2B StarterStarter
SubscriptionsSubscriptions recipe with a custom moduleOfficial guide

PCI scope for a custom checkout

PCI scope depends on how card data reaches Stripe, not on who builds the checkout page. Stripe's PCI guide puts Checkout and Elements integrations under SAQ A, because card fields sit in an iframe served by Stripe, and puts integrations that send card data directly to Stripe's API under SAQ D. With Medusa and Stripe Elements, the card form is Stripe's, and the Medusa backend works with payment sessions and Stripe IDs, not card numbers. You still attest to PCI compliance yourself each year, as Stripe's security guide explains.

How this compares with Shopify checkout

Shopify's checkout is hosted by Shopify; a headless Storefront API cart hands buyers a Copy to clipboardcheckoutUrl that goes through Shopify's web checkout. Copy to clipboardcheckout.liquid is unsupported for the information, shipping, and payment steps, and checkout UI extensions on those steps are available only on Shopify Plus. In Medusa, those steps are your own screens, and completion rules live in your workflows.

When Medusa is not the right choice

If Shopify's checkout already converts well for you and you only need small additions, extensions on Plus are less work than owning a checkout. Owning the checkout also means owning its testing, accessibility, and payment edge cases.

Common questions

Can I build a one-page checkout with Medusa?

Yes. Medusa's checkout steps are Store API calls on a cart, and the docs say you can combine steps or change their order, so one sdk.store.cart.update call can save the email and both addresses on a single screen.

Does Medusa support Apple Pay and Google Pay?

Through Stripe, yes. Medusa's Stripe Module Provider has an automatic_payment_methods option for wallets like Apple Pay and Google Pay, and you render them with Stripe's components in your storefront.

Does a custom checkout put me in a higher PCI scope?

Not if card fields come from Stripe. Stripe says Checkout and Elements integrations qualify for SAQ A because card data goes to Stripe in an iframe, while sending card data directly to Stripe's API means SAQ D.

How do I run code after a Medusa order is placed?

Create a subscriber for the order.placed event, which completeCartWorkflow emits. Use the workflow's validate hook only for checks that should block the order.