How to build a fully custom checkout flow
- Type
- 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 clipboard
medusa-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.
12345await 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.
12345678910// src/workflows/hooks/validate-checkout.tsimport { completeCartWorkflow } from "@medusajs/medusa/core-flows"import { MedusaError } from "@medusajs/framework/utils"completeCartWorkflow.hooks.validate(async ({ cart }, { container }) => {const allowed = true // your rule, using cartif (!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.
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.