3 MIN READ

Medusa Workflows Explained: Steps, Compensation and Why They Matter

Workflows are how Medusa keeps multi-step commerce operations from leaving your data half-written. How steps and compensation work, and how to build one properly.

BY RAHUL MEHTAUPDATED
Illustration for “Medusa Workflows Explained: Steps, Compensation and Why They Matter” — Workflows

Every commerce operation worth anything touches several systems. Placing an order reserves inventory, charges a card, writes a record and notifies a warehouse. Four operations, four chances to fail, and no database transaction that spans them all — the card charge already happened at a company you do not control.

Workflows are Medusa's answer: a sequence of steps, each of which knows how to undo itself.

The problem, concretely

Without compensation, this is what a failure looks like:

  1. Reserve 3 units of inventory. ✅
  2. Charge the customer $240. ✅
  3. Create the order record. ❌ Database timeout.

You now have a customer charged $240, three units of stock reserved, and no order. Nobody knows. The customer emails in four days.

With a workflow, step three's failure triggers step two's compensation (refund) and step one's (release), in that order. The customer sees an error, the data is consistent, and you sleep.

A step

src/workflows/steps/reserve-inventory.tsts
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"

type Input = {
  variant_id: string
  quantity: number
  location_id: string
}

export const reserveInventoryStep = createStep(
  "reserve-inventory",
  async ({ variant_id, quantity, location_id }: Input, { container }) => {
    const inventory = container.resolve("inventory")

    const reservation = await inventory.createReservationItems([
      { inventory_item_id: variant_id, location_id, quantity },
    ])

    // First argument: what the step returns.
    // Second: what the compensation function receives.
    return new StepResponse(reservation[0], reservation[0].id)
  },
  // The compensation function.
  async (reservationId: string, { container }) => {
    if (!reservationId) return
    const inventory = container.resolve("inventory")
    await inventory.deleteReservationItems(reservationId)
  }
)

The shape to internalise: StepResponse(output, compensationInput). The second argument is the only thing the compensation function receives, so it must carry everything needed to undo the work — usually an id.

A workflow

src/workflows/place-custom-order.tsts
import { createWorkflow, WorkflowResponse, transform } from "@medusajs/framework/workflows-sdk"
import { reserveInventoryStep } from "./steps/reserve-inventory"
import { chargePaymentStep } from "./steps/charge-payment"
import { createOrderRecordStep } from "./steps/create-order-record"

type Input = {
  cart_id: string
  variant_id: string
  quantity: number
  location_id: string
  amount: number
}

export const placeCustomOrderWorkflow = createWorkflow(
  "place-custom-order",
  (input: Input) => {
    const reservation = reserveInventoryStep({
      variant_id: input.variant_id,
      quantity: input.quantity,
      location_id: input.location_id,
    })

    const payment = chargePaymentStep({
      cart_id: input.cart_id,
      amount: input.amount,
    })

    const order = createOrderRecordStep({
      cart_id: input.cart_id,
      payment_id: payment.id,
      reservation_id: reservation.id,
    })

    return new WorkflowResponse(order)
  }
)

If createOrderRecordStep throws, the payment is refunded and the reservation released, in that order, automatically.

The rule that trips everyone up

The workflow body runs once, at definition time, to build a graph. It does not run per invocation.

So this does not work:

ts
// ✗ Broken — `total` is a reference to a future value, not a number.
const order = createOrderStep({ total: input.amount * 1.2 })

At definition time input.amount is a placeholder. Multiplying it produces nonsense. Use transform, which defers the computation to runtime:

ts
// ✓ Correct
const totals = transform({ input }, ({ input }) => ({
  total: input.amount * 1.2,
}))

const order = createOrderStep({ total: totals.total })

Same for conditionals — use when rather than a native if:

ts
import { when } from "@medusajs/framework/workflows-sdk"

when({ input }, ({ input }) => input.requires_approval)
  .then(() => {
    createApprovalRequestStep({ order_id: input.cart_id })
  })

Nearly every "my workflow behaves strangely" bug is one of these two.

Running one

From an API route:

src/api/store/custom-orders/route.tsts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { placeCustomOrderWorkflow } from "../../../workflows/place-custom-order"

export async function POST(req: MedusaRequest, res: MedusaResponse) {
  const { result } = await placeCustomOrderWorkflow(req.scope).run({
    input: req.body as never,
  })

  res.json({ order: result })
}

Workflows also run from subscribers and scheduled jobs. The invocation is always the same shape.

Composing workflows

Medusa's own core flows are workflows, and you can run them as steps inside yours:

ts
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"

const created = createProductsWorkflow.runAsStep({ input: payload })

Compensation composes too — if your workflow fails after the nested one succeeded, the nested workflow's compensations run. This is why importers should be workflows: a failed import rolls itself back instead of leaving half a catalog.

Where they earn their keep

Use caseWhy a workflow
Order placementMultiple external systems, all reversible
Data importPartial imports are worse than none
Subscription billingCharge, extend, notify — each undoable
Marketplace payoutsMoney moves; compensation is mandatory
ERP syncRemote failure must not corrupt local state

If an operation touches one module and cannot half-fail, a service method is fine. The moment there are two external calls, use a workflow.

Practical guidance

Make steps idempotent. Retries happen. A step that double-charges on retry is a bug waiting for a bad network day.

Compensation must be defensive. It may run against a partially completed step. Check the id exists before deleting it.

Keep steps small. One external effect per step. Fat steps cannot be compensated precisely.

Name steps in the imperative. reserve-inventory, not inventory-handler. The name appears in logs when things fail.

Long-running and async steps

Not every step completes within a request. A step awaiting a supplier confirmation, a manual approval or a webhook can be marked async, which pauses the workflow until something resolves it:

src/workflows/steps/await-supplier-confirmation.tsts
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"

export const awaitSupplierConfirmationStep = createStep(
  { name: "await-supplier-confirmation", async: true, timeout: 60 * 60 * 24 },
  async (input: { order_id: string }, { container }) => {
    await container.resolve("supplier").requestConfirmation(input.order_id)
    // No StepResponse: the workflow pauses here until something resolves it.
  },
)

An API route or subscriber then resolves it when the confirmation arrives, and the workflow continues from that point. This requires the Redis workflow engine — the in-memory default cannot hold a paused workflow across a restart. See production deployment.

Debugging a workflow

Three techniques, in the order they are usually needed:

Read the step names in the logs. Each step logs on start and finish under the name you gave it, which is the argument for imperative names like reserve-inventory over inventory-handler.

Log inside transform, not the workflow body. The body runs once at definition time, so a console.log there prints a reference object exactly once at boot. Inside transform it prints real values per run.

Force a failure deliberately. Throw from the last step in a test and assert the world is unchanged. Compensation is the code path that never runs in development, which is exactly why it hides bugs — see testing.


Workflows are the part of Medusa that separates a store which recovers from failures from one that accumulates them. We are happy to review yours.

Frequently asked questions

What is the difference between a workflow and a service method in Medusa?

A service method belongs to a single module and does one thing. A workflow orchestrates steps across modules and external systems, with rollback when a later step fails. Single module and no external calls means a service method; anything crossing boundaries should be a workflow.

How does compensation work in Medusa workflows?

Each step may define a compensation function that receives the second argument of its `StepResponse`. If any later step throws, Medusa runs the compensations of completed steps in reverse order, undoing the work. It is a saga pattern, built in.

Why does my workflow show undefined values at definition time?

Because the workflow body runs once to build a graph, not per invocation. Values passed between steps are references resolved at runtime. To compute anything from them, use `transform`; to branch, use `when` rather than a native `if`.

Can I run a Medusa workflow from a subscriber?

Yes, and it is a common pattern — a subscriber reacts to an event and invokes a workflow to do the work. The subscriber gets the container, so `myWorkflow(container).run({ input })` works exactly as it does in an API route.

Are Medusa workflows durable across restarts?

Steps can be marked async and paused for external confirmation, which supports long-running flows. Ordinary in-process workflows do not survive a crash mid-execution, which is exactly why compensation exists — a failed run leaves consistent data rather than a resumable one.

Can I use Medusa's built-in workflows?

Yes. Core flows like `createProductsWorkflow` and `createCartWorkflow` are exported from `@medusajs/medusa/core-flows` and can be run as steps inside your own, inheriting their compensation. Prefer composing them over reimplementing core behaviour.

[ Keep reading ]