3 MIN READ

Medusa Fulfilment: Stock Locations, Service Zones and Carriers

Why your shipping options are empty, how the fulfilment model actually fits together, and how to build a provider that talks to a real carrier.

BY RAHUL MEHTAUPDATED
Illustration for “Medusa Fulfilment: Stock Locations, Service Zones and Carriers” — Fulfillment

"There are no shipping options for this cart" is the single most common Medusa support question, and it is almost always the same missing link in a chain of five concepts. Once the chain is clear, the rest of fulfilment is straightforward.

The chain

ConceptWhat it represents
Stock locationA physical place inventory lives
Fulfilment setA group of service zones served from a location
Service zoneThe geography served — countries, regions
Shipping profileWhich products a shipping option applies to
Shipping optionWhat the customer picks: name, price, provider

For a cart to see an option, all of these must be true: a stock location exists and holds the inventory, it is linked to a fulfilment set, that set has a service zone covering the destination country, a shipping option exists in that zone with a matching profile, and its price rules match the cart.

Work down that list in order when options are missing. It is nearly always the service zone.

Setting it up

src/scripts/setup-fulfillment.tsts
import { ExecArgs } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"

export default async function setupFulfillment({ container }: ExecArgs) {
  const fulfillment = container.resolve(Modules.FULFILLMENT)
  const stockLocation = container.resolve(Modules.STOCK_LOCATION)

  const [location] = await stockLocation.createStockLocations([
    {
      name: "Main warehouse",
      address: { city: "Faridabad", country_code: "in", address_1: "J 62" },
    },
  ])

  const [set] = await fulfillment.createFulfillmentSets([
    {
      name: "Domestic shipping",
      type: "shipping",
      service_zones: [
        {
          name: "India",
          geo_zones: [{ type: "country", country_code: "in" }],
        },
      ],
    },
  ])

  // The link is what most setups miss.
  await fulfillment.createFulfillmentSetLocation?.({
    fulfillment_set_id: set.id,
    location_id: location.id,
  })
}

Run it with npx medusa exec ./src/scripts/setup-fulfillment.ts. Most teams do this in the admin instead, which is fine — but scripting it makes environments reproducible.

Flat rate versus calculated

Flat rate options carry a fixed price per currency. Fast, predictable, and right for most stores.

Calculated options call your fulfilment provider when the customer reaches shipping, passing the cart, and receive a price back. Necessary when weight, dimensions or destination genuinely determine cost.

The trade is latency and failure modes: a slow carrier API delays checkout, and a failing one must not remove all shipping options. Cache aggressively, set a short timeout, and fall back to a flat rate rather than an empty list.

A custom provider

src/modules/fulfillment-acme/service.tsts
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"

class AcmeFulfillmentProvider extends AbstractFulfillmentProviderService {
  static identifier = "acme"

  async getFulfillmentOptions() {
    return [
      { id: "acme-standard", name: "Standard (3–5 days)" },
      { id: "acme-express", name: "Express (next day)", is_return: false },
    ]
  }

  async validateFulfillmentData(optionData: any, data: any, context: any) {
    return { ...data }
  }

  async canCalculate() {
    return true
  }

  async calculatePrice(optionData: any, data: any, context: any) {
    const weightGrams = (context.items ?? []).reduce(
      (sum: number, item: any) => sum + (item.variant?.weight ?? 250) * item.quantity,
      0,
    )

    const rate = await fetch("https://api.acme-courier.test/rates", {
      method: "POST",
      body: JSON.stringify({
        service: optionData.id,
        weight_g: weightGrams,
        destination: context.shipping_address?.postal_code,
      }),
    }).then((r) => r.json())

    return { calculated_amount: rate.amount, is_calculated_price_tax_inclusive: false }
  }

  async createFulfillment(data: any, items: any[], order: any, fulfillment: any) {
    const shipment = await createShipment(order, items)
    return {
      data: { shipment_id: shipment.id },
      labels: [
        {
          tracking_number: shipment.tracking_number,
          tracking_url: shipment.tracking_url,
          label_url: shipment.label_url,
        },
      ],
    }
  }

  async cancelFulfillment(data: any) {
    await voidShipment(data.shipment_id)
    return {}
  }
}

export default AcmeFulfillmentProvider

The important method is createFulfillment: it books the shipment and returns tracking numbers, which flow into the order and into customer notifications. Returning tracking data here is also what makes PayPal dispute defence automatic rather than manual.

Register it like a payment provider — in the fulfilment module's options, then selectable on a shipping option.

Multi-location

With several stock locations, Medusa can source an order from the location that holds the inventory. That means:

  • Inventory is tracked per location, not globally.
  • Service zones per location express who ships where.
  • An order may split into multiple fulfilments.

Do not model multi-location until you actually have it. A single location with a single fulfilment set is dramatically simpler, and premature multi-location modelling is a common source of "no shipping options" confusion.

Debugging empty options

In order:

  1. Does the destination country appear in a service zone?
  2. Is the fulfilment set linked to a stock location?
  3. Does the stock location hold inventory for the cart's items?
  4. Does the shipping option's profile match the products?
  5. Do the option's price rules cover the cart's region and currency?
  6. For calculated options, is the provider returning a price, or erroring?

Ninety percent of cases stop at step one.

Returns

Returns run through the same fulfilment machinery in reverse, and the provider interface supports return shipping options separately from outbound ones:

ts
async getFulfillmentOptions() {
  return [
    { id: "acme-standard", name: "Standard (3–5 days)" },
    { id: "acme-express", name: "Express (next day)" },
    // Return options are declared, not inferred.
    { id: "acme-return", name: "Return label", is_return: true },
  ]
}

The operational decisions matter more than the code:

Who pays? Free returns lift conversion and cost margin. A common middle ground is free for faulty or wrong items, customer-paid otherwise, with the reason code deciding.

Label now or on approval? Sending a label immediately is a better experience and increases return volume. Requiring approval reduces volume and adds friction. Pick deliberately.

When is the refund issued? On receipt is safest; on scan is friendlier. On scan with a reconciliation job that flags never-arrived returns is the balance most brands land on.

Multi-package shipments

An order that ships in two boxes is one fulfilment with two labels, not two fulfilments. Return both from createFulfillment:

ts
return {
  data: { shipment_id: shipment.id },
  labels: shipment.packages.map((pkg) => ({
    tracking_number: pkg.tracking_number,
    tracking_url: pkg.tracking_url,
    label_url: pkg.label_url,
  })),
}

Then make sure your shipment notification email lists all tracking numbers. A customer who receives one tracking link for a two-box order contacts support when the first box arrives incomplete — which is a support cost created entirely by the email template.

Partial fulfilment

Orders do not always ship complete. One item is backordered, another is in a different warehouse, and the customer expects the available part now.

Medusa supports several fulfilments per order, each covering a subset of line items. What that requires from you:

  • A shipment email per fulfilment, saying which items are in this box and which are still to come. Silence after a partial shipment generates a support ticket every time.
  • Capture logic that matches your policy — capture in full at first shipment, or capture proportionally per fulfilment. Decide, and encode it.
  • An order status that reflects reality. "Partially fulfilled" is a state customers understand; "processing" for two weeks is not.

Get this right and backorders become a normal operational state instead of a source of complaints.


Fulfilment configuration is fiddly once and stable forever. Happy to set it up.

Frequently asked questions

Why does my Medusa cart have no shipping options?

Almost always because no service zone covers the destination country, or the fulfilment set is not linked to a stock location. Check the geography first, then the link, then whether the shipping option's profile and price rules match the cart.

What is a service zone in Medusa?

The geographic scope of a fulfilment set — the countries or regions it serves. Shipping options belong to a service zone, so a destination outside every zone produces no options at all.

What is the difference between flat-rate and calculated shipping?

Flat-rate options have a fixed price per currency. Calculated options ask your fulfilment provider for a price at checkout, based on the cart's weight, dimensions and destination. Calculated rates are more accurate and add latency plus a failure mode.

How do I add a custom carrier to Medusa?

Write a fulfilment provider module extending `AbstractFulfillmentProviderService`, implementing the option list, price calculation and fulfilment creation, then register it in the fulfilment module and select it on a shipping option.

Can Medusa ship from multiple warehouses?

Yes. Multiple stock locations each track their own inventory and can have their own service zones, and an order may split across several fulfilments. Only model this when you genuinely operate multiple locations — it adds real complexity.

Where do tracking numbers come from?

From the fulfilment provider's `createFulfillment`, which returns labels with tracking numbers and URLs. Those attach to the order and feed shipment notification emails and dispute evidence.

[ Keep reading ]