Skip to main content
Commerce

Medusa v2 Custom Fulfillment Provider for India: A Complete Guide (2026)

Build a Medusa v2 custom fulfillment provider — every AbstractFulfillmentProviderService method, folder shape, config, and the calculatePrice trap.

MManojAugust 9, 202614 min read
More in Commerce#medusa#fulfilment#india
Share

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 identifierProvider registration bootYes
getFulfillmentOptions()Admin lists shipping options for a service zoneYes
validateOption(data)A shipping option is being created / updated in AdminYes
validateFulfillmentData(optionData, data, context)A shipping method is added to a cartYes
canCalculate(data)Cart checks whether a shipping option supports dynamic pricingOnly if dynamic
calculatePrice(optionData, data, context)Cart total is being computed for a calculated shipping optionOnly if dynamic
createFulfillment(data, items, order, fulfillment)Order is being fulfilled (Admin or workflow)Yes for real providers
cancelFulfillment(data)A pending fulfillment is cancelledYes for real providers
createReturnFulfillment(fulfillment)A customer return is initiatedOptional
getFulfillmentDocuments(data)Admin requests fulfillment paperworkOptional
getShipmentDocuments(data)Admin requests shipment paperworkOptional
getReturnDocuments(data)Admin requests return paperworkOptional

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:

  1. The provider module isn't registered. If your constructor's this.logger_.info never appears at boot, medusa-config.ts is not resolving the module. Check the resolve path (relative to the config file) and re-run medusa develop. The provider should be listed under Admin → Settings → Shipping Profiles.
  2. The shipping option was created with price_type: "flat". In Admin → Settings → Regions → Shipping Options, the option's price type must be calculated, not flat. A flat option bypasses calculatePrice and uses the flat amount as-is. Delete and recreate the option with calculated if you got this wrong.
  3. canCalculate returns false or is missing. The default implementation on AbstractFulfillmentProviderService returns false. Explicitly override it to return true for 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.

Next step · Commerce

Now see what a store you own would look like

How we build and run Medusa storefronts and B2B portals connected to ERPNext, and when staying on a hosted platform is the better call.

M

Written by

Manoj

Founder of MithTech, an open-source ERP & automation engineering practice. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

Already a MithTech client?

Help the next operator choose.

Most teams evaluating ERPNext have no way to tell who actually delivers. If we’ve run an implementation for you, two lines on Google count for more than anything we can write about ourselves.

Leave a Google review

Only if we’ve actually worked together — Google filters reviews from non-customers, so an honest one is worth more than ten polite ones.

Keep reading

See what this looks like for your business

A 30-minute working session with a principal consultant. We pressure-test the architecture and outline the engagement model that fits your governance and procurement posture. You leave with a written brief.

0
Published on 9 August 2026

Manoj

Comments & ratings

No comments yet. Start a new discussion.