A Medusa v2 custom fulfillment provider is a small module that extends AbstractFulfillmentProviderService, gets registered under the Fulfillment Module in medusa-config.ts, and implements the twelve methods Medusa calls throughout the order lifecycle. For anyone shipping in India, this is how Shiprocket, Delhivery, Bluedart, DTDC or a regional courier gets wired into a Medusa store — the manual fp_manual_manual default handles nothing. This guide walks the full method surface, the folder layout, the registration, and the one bug where calculatePrice silently never fires.
The Fulfillment Module in v2 is deliberately empty out of the box — you get one provider called manual that literally does nothing except let Admin mark an order as fulfilled by hand. Every India store I've built has replaced it in the first week. I lead Medusa v2 engineering programmes at MithTech, an enterprise software practice headquartered in Bangalore that designs, builds and operates commerce systems for Indian retailers and B2B operators. This guide is the reference we hand every developer writing their first custom provider.
What does a v2 fulfillment provider actually look like?
Answer
A Medusa v2 custom fulfillment provider is a TypeScript class that extends AbstractFulfillmentProviderService, declares a static identifier, and implements up to twelve lifecycle methods. Medusa calls these methods at specific points — getFulfillmentOptions when a merchant configures shipping options, calculatePrice at cart price computation, createFulfillment when an order is marked to ship, cancelFulfillment when it's cancelled. The provider is a module registered under the Fulfillment Module in medusa-config.ts.
Skimmable summary: one class, twelve methods, one config entry.
The complete method surface from the Create Fulfillment Module Provider reference:
12 rows · click a column to sort
| Called when | ||
|---|---|---|
static identifier | Provider registration boot | Yes |
getFulfillmentOptions() | Admin lists shipping options for a service zone | Yes |
validateOption(data) | A shipping option is being created / updated in Admin | Yes |
validateFulfillmentData(optionData, data, context) | A shipping method is added to a cart | Yes |
canCalculate(data) | Cart checks whether a shipping option supports dynamic pricing | Only if dynamic |
calculatePrice(optionData, data, context) | Cart total is being computed for a calculated shipping option | Only if dynamic |
createFulfillment(data, items, order, fulfillment) | Order is being fulfilled (Admin or workflow) | Yes for real providers |
cancelFulfillment(data) | A pending fulfillment is cancelled | Yes for real providers |
createReturnFulfillment(fulfillment) | A customer return is initiated | Optional |
getFulfillmentDocuments(data) | Admin requests fulfillment paperwork | Optional |
getShipmentDocuments(data) | Admin requests shipment paperwork | Optional |
getReturnDocuments(data) | Admin requests return paperwork | Optional |
The default fp_manual_manual provider only implements the boilerplate — it's a placeholder. Every real courier integration needs at least the Required?: Yes rows.
How do you scaffold a v2 provider module?
Skimmable summary: two files under src/modules/<name>/, one entry in medusa-config.ts.
For a Shiprocket provider:
src/modules/shiprocket-fulfillment/
├── service.ts # extends AbstractFulfillmentProviderService
└── index.ts # exports ModuleProvider(Modules.FULFILLMENT, { services: [ShiprocketService] })
src/modules/shiprocket-fulfillment/service.ts:
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"
import { Logger } from "@medusajs/framework/types"
type InjectedDependencies = { logger: Logger }
type Options = { apiToken: string; pickupPostcode: string }
export default class ShiprocketService extends AbstractFulfillmentProviderService {
static identifier = "shiprocket"
protected logger_: Logger
protected options_: Options
constructor({ logger }: InjectedDependencies, options: Options) {
super()
this.logger_ = logger
this.options_ = options
}
// ... method implementations follow
}
src/modules/shiprocket-fulfillment/index.ts:
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import ShiprocketService from "./service"
export default ModuleProvider(Modules.FULFILLMENT, {
services: [ShiprocketService],
})
medusa-config.ts registration:
export default defineConfig({
modules: [
{
resolve: "@medusajs/medusa/fulfillment",
options: {
providers: [
{
resolve: "./src/modules/shiprocket-fulfillment",
id: "shiprocket",
options: {
apiToken: process.env.SHIPROCKET_API_TOKEN,
pickupPostcode: process.env.SHIPROCKET_PICKUP_POSTCODE,
},
},
],
},
},
],
})
At boot, Medusa registers this as provider id fp_shiprocket_shiprocket (the pattern is fp_{identifier}_{id}). If nothing shows up in Admin → Settings → Regions → Shipping Options after this, the module isn't registered — check the paths and restart.
What must getFulfillmentOptions return?
Skimmable summary: an array of options the courier supports; each option becomes a selectable shipping method in Admin.
getFulfillmentOptions runs when a merchant is configuring shipping options for a service zone (e.g., "India domestic" or "Bangalore local"). It returns whatever fulfillment options your courier offers — for Shiprocket, that could be Surface, Air, Same-day, Next-day; for Delhivery, Express and Standard. Medusa doesn't parse the shape strictly; only id is required.
async getFulfillmentOptions() {
return [
{ id: "shiprocket-surface", name: "Shiprocket Surface (3–5 days)" },
{ id: "shiprocket-air", name: "Shiprocket Air (1–2 days)" },
{ id: "shiprocket-express", name: "Shiprocket Express (Same-day metros)" },
]
}
These IDs surface in Admin. When the merchant creates a shipping option and picks "Shiprocket Air," Medusa stores id: "shiprocket-air" in the option's data — you'll receive it back on subsequent calls in optionData.
validateOption vs validateFulfillmentData: what's the difference?
Skimmable summary: validateOption runs when the merchant configures the option in Admin; validateFulfillmentData runs when a customer picks it at checkout.
validateOption fires once, at shipping option create/update time — the merchant is defining what "Shiprocket Air" means. Use this to reject options with bad configuration (e.g., an ID your courier doesn't support):
async validateOption(data: Record<string, unknown>): Promise<boolean> {
const valid = ["shiprocket-surface", "shiprocket-air", "shiprocket-express"]
return valid.includes(data.id as string)
}
validateFulfillmentData fires on every checkout when the customer selects this shipping option. Use it to enrich or validate the per-order data:
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
context: Record<string, unknown>,
): Promise<Record<string, unknown>> {
// optionData = what the merchant configured
// data = per-cart/order data (from the storefront)
// context = { customer, cart } — use for pincode serviceability
const cart = context.cart as { shipping_address?: { postal_code?: string } }
const pincode = cart?.shipping_address?.postal_code
if (!pincode) throw new Error("Shipping pincode required")
const serviceable = await this.checkServiceability(pincode)
if (!serviceable) throw new Error(`Shiprocket does not serve ${pincode}`)
return { ...data, verified_pincode: pincode }
}
The returned object is stored on the shipping method — you get it back in every subsequent method call for this order.
How do canCalculate and calculatePrice work together?
Skimmable summary: canCalculate is the on/off switch for dynamic pricing; calculatePrice returns the actual quote.
For a courier like Shiprocket or Delhivery where the shipping cost depends on weight, distance, and dimensions, you need dynamic pricing. Two things must be true:
1. The shipping option in Admin must be created with price_type: "calculated" (not "flat").
If the shipping option was set up in Admin with a flat rate, calculatePrice never fires — Medusa uses the flat rate as-is. This is the single most common cause of "my calculatePrice isn't being called" tickets (Discussion #9495).
2. Your provider implements both methods.
async canCalculate(data: Record<string, unknown>): Promise<boolean> {
// Return true for any option that supports dynamic pricing.
// For Shiprocket, all options are calculated.
return true
}
async calculatePrice(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
context: Record<string, unknown>,
): Promise<{ calculated_amount: number; is_calculated_price_tax_inclusive: boolean }> {
const cart = context.cart as {
shipping_address?: { postal_code?: string }
items?: Array<{ variant?: { weight?: number } }>
}
const pincode = cart?.shipping_address?.postal_code
const totalWeight = (cart?.items ?? []).reduce(
(sum, item) => sum + (item.variant?.weight ?? 500),
0,
)
const quote = await this.getShiprocketQuote({
pickupPostcode: this.options_.pickupPostcode,
deliveryPostcode: pincode ?? "",
weightGrams: totalWeight,
courier: optionData.id,
})
return {
calculated_amount: quote.rate * 100, // Medusa expects integer minor units
is_calculated_price_tax_inclusive: false,
}
}
Return calculated_amount in minor units (paise for INR) — an amount of 5000 means ₹50, not ₹5000. This trips people once and only once.
What does createFulfillment need to do?
Skimmable summary: push the shipment to your courier, capture whatever you need to cancel or track later.
createFulfillment runs when a fulfillment moves from "pending" to "shipping" — either an Admin user clicked "Fulfill" or a workflow triggered it. This is where you actually create the shipment at the courier's end and get back a waybill / AWB number:
async createFulfillment(
data: Record<string, unknown>,
items: Array<Record<string, unknown>>,
order: Record<string, unknown> | undefined,
fulfillment: Record<string, unknown>,
): Promise<{ data: Record<string, unknown>; labels: Array<Record<string, unknown>> }> {
// data = shipping method data (from validateFulfillmentData)
// items = the line items being fulfilled
// order = the parent order
// fulfillment = the fulfillment record being created
const shiprocketOrder = await this.createShiprocketOrder({
orderId: (order as { display_id: string }).display_id,
items,
shippingAddress: (order as { shipping_address: unknown }).shipping_address,
verifiedPincode: data.verified_pincode,
})
// Persist enough context to cancel or track later
return {
data: {
shiprocket_order_id: shiprocketOrder.order_id,
awb_code: shiprocketOrder.awb_code,
courier_company_id: shiprocketOrder.courier_company_id,
},
labels: [
{
tracking_number: shiprocketOrder.awb_code,
tracking_url: shiprocketOrder.tracking_url,
label_url: shiprocketOrder.label_url,
},
],
}
}
The returned data is stored on the fulfillment record — you'll receive it back in cancelFulfillment. labels populates the "Print label" action in Admin.
cancelFulfillment is the mirror image — Medusa hands you back exactly the data you returned above, and you call the courier's cancel endpoint:
async cancelFulfillment(data: Record<string, unknown>): Promise<Record<string, unknown>> {
await this.cancelShiprocketOrder({
orderId: data.shiprocket_order_id as string,
awbCode: data.awb_code as string,
})
return { cancelled_at: new Date().toISOString() }
}
Why does my calculatePrice never get called?
Skimmable summary: three specific misconfigurations. Walk them before assuming a framework bug.
Discussion #9495 documents this precisely: the developer's constructor never runs, canCalculate and calculatePrice never fire, and shipping options fail with "do not have a price." Three specific causes, in order:
- The provider module isn't registered. If your constructor's
this.logger_.infonever appears at boot,medusa-config.tsis not resolving the module. Check theresolvepath (relative to the config file) and re-runmedusa develop. The provider should be listed under Admin → Settings → Shipping Profiles. - The shipping option was created with
price_type: "flat". In Admin → Settings → Regions → Shipping Options, the option's price type must becalculated, notflat. A flat option bypassescalculatePriceand uses the flat amount as-is. Delete and recreate the option withcalculatedif you got this wrong. canCalculatereturns false or is missing. The default implementation onAbstractFulfillmentProviderServicereturns false. Explicitly override it to returntruefor options that support dynamic pricing.
If all three are correct and it still doesn't fire, cross-check with the interactive flow in Medusa v2 module isolation cross-module query — a workflow step trying to resolve the fulfillment service directly instead of using the Query graph can produce a similar silent-nothing symptom.
FAQ
The four questions we field most on custom fulfillment providers.
Do I need a separate module for each courier, or can one provider handle multiple?
Both patterns work. One module with a services: [ShiprocketService, DelhiveryService, DTDCService] array is cleaner when the couriers share auth or config. Separate modules are cleaner when they have independent lifecycles or you want to enable/disable them per environment. Start with separate modules if unsure — you can always consolidate.
Where do I store the courier's API credentials?
In the options block of the provider's medusa-config.ts entry, sourced from environment variables. The constructor({ logger }, options) signature receives them typed. Do not hardcode credentials in the service file — they end up in git, get rotated painfully, and don't survive an environment change.
How do I test the provider locally without hitting the courier's live API?
Two clean patterns. First, most Indian couriers offer a sandbox / UAT credential — Shiprocket and Delhivery both do. Use that in dev. Second, if you want unit-level testing, extract the API client into a separate class and mock it in your service tests — the fulfillment provider itself is a thin adapter that's easy to test with a stubbed client.
Can I use a v1 fulfillment plugin (npm package) on v2?
No. v1 fulfillment plugins extend a different base class (AbstractFulfillmentService) and use v1's DI conventions. The v2 abstraction is AbstractFulfillmentProviderService from @medusajs/framework/utils with a different method surface. Some community v1 plugins have been ported — check the npm listing before you write from scratch — but the code you find on npm predating v2 will not work.
Wiring up a custom fulfillment provider on Medusa v2?
MithTech designs, builds and operates Medusa v2 commerce systems for Indian retailers and B2B operators — Shiprocket, Delhivery, Bluedart and regional-courier integrations, plus the workflow layer that keeps order status, RTOs and webhooks in sync. If your calculatePrice is not firing or your createFulfillment is throwing at the courier edge, we can help you land the integration cleanly.