Integration of delivery services into Medusa.js
Medusa.js is a headless commerce framework on Node.js built around the concept of plugins. Delivery is implemented through FulfillmentProvider: classes that encapsulate interaction with a specific carrier. The standard manual provider only creates records in the database — without tariff calculation and without sending data to the carrier.
Fulfillment Provider Architecture
Each provider is a service registered in the Medusa container. Basic interface:
import { AbstractFulfillmentService } from '@medusajs/medusa';
import { Cart, Fulfillment, LineItem, Order } from '@medusajs/medusa/dist/models';
class MyCourierFulfillmentService extends AbstractFulfillmentService {
static identifier = 'my-courier';
// Available shipping methods (displayed in shipping options)
async getFulfillmentOptions(): Promise<Record<string, unknown>[]> {
return [
{ id: 'my-courier-standard', name: 'Standard' },
{ id: 'my-courier-express', name: 'Express' },
];
}
// Validate option when creating ShippingOption in admin
async validateOption(data: Record<string, unknown>): Promise<boolean> {
return ['my-courier-standard', 'my-courier-express'].includes(
data.id as string
);
}
// Calculate cost for specific cart
async calculatePrice(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
cart: Cart
): Promise<number> {
const weight = cart.items.reduce(
(sum, item) => sum + (item.variant?.weight ?? 100) * item.quantity,
0
);
const toCity = cart.shipping_address?.city ?? '';
const price = await this.getApiRate(optionData.id as string, weight, toCity);
return price; // in smallest currency units (kopecks/cents)
}
// Create shipment
async createFulfillment(
data: Record<string, unknown>,
items: LineItem[],
order: Order,
fulfillment: Fulfillment
): Promise<Record<string, unknown>> {
const shipment = await this.apiClient.createShipment({
service: data.id,
recipient: order.shipping_address,
items: items.map(i => ({ sku: i.variant?.sku, qty: i.quantity })),
order_ref: order.display_id.toString(),
});
return { tracking_number: shipment.tracking, shipment_id: shipment.id };
}
// Cancel shipment
async cancelFulfillment(fulfillment: Fulfillment): Promise<Record<string, unknown>> {
await this.apiClient.cancelShipment(fulfillment.data.shipment_id as string);
return {};
}
// Whether return is needed — provider decides
async canCalculate(data: Record<string, unknown>): Promise<boolean> {
return true;
}
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
cart: Cart
): Promise<Record<string, unknown>> {
return data;
}
}
export default MyCourierFulfillmentService;
Registration in Medusa project
In medusa-config.js, the plugin is connected via plugins array if packaged as npm. For local development, registering the service directly is sufficient:
// src/services/my-courier-fulfillment.ts — same class as above
// src/loaders/fulfillment.ts
import { asClass } from 'awilix';
export default async (container) => {
container.register({
myСourierFulfillmentService: asClass(MyCourierFulfillmentService).singleton(),
});
};
In Medusa v2 (with modular architecture), the provider is declared via defineProvider:
// In the delivery module
import { ModuleProvider, Modules } from '@medusajs/utils';
export default ModuleProvider(Modules.FULFILLMENT, {
services: [MyCourierFulfillmentService],
});
HTTP client for carrier API
// src/services/my-courier-api.ts
import axios, { AxiosInstance } from 'axios';
export class MyCourierApiClient {
private client: AxiosInstance;
constructor(apiKey: string) {
this.client = axios.create({
baseURL: 'https://api.mycourier.ru/v2',
timeout: 10_000,
headers: { Authorization: `Bearer ${apiKey}` },
});
}
async getRates(serviceCode: string, weight: number, toCity: string): Promise<number> {
const { data } = await this.client.post('/calculate', {
service: serviceCode,
weight: Math.max(0.1, weight / 1000), // grams -> kg
to_city: toCity,
});
// Return in kopecks for Medusa
return Math.round(data.price * 100);
}
async createShipment(payload: object): Promise<{ tracking: string; id: string }> {
const { data } = await this.client.post('/shipments', payload);
return data;
}
async cancelShipment(shipmentId: string): Promise<void> {
await this.client.delete(`/shipments/${shipmentId}`);
}
}
Webhook: order status update
Medusa supports events via EventBus. Webhook from carrier goes to custom route:
// src/api/routes/webhooks/courier.ts
import { Router } from 'express';
import type { MedusaRequest, MedusaResponse } from '@medusajs/medusa';
const router = Router();
router.post('/courier/webhook', async (req: MedusaRequest, res: MedusaResponse) => {
const { tracking_number, status, event } = req.body;
// Find fulfillment by tracking number
const fulfillmentRepo = req.scope.resolve('fulfillmentRepository');
const fulfillment = await fulfillmentRepo.findOne({
where: { data: { tracking_number } },
});
if (!fulfillment) {
return res.sendStatus(404);
}
const orderService = req.scope.resolve('orderService');
if (event === 'delivered') {
await orderService.capturePayment(fulfillment.order_id);
// or just update metadata
}
const eventBus = req.scope.resolve('eventBusService');
await eventBus.emit('fulfillment.tracking_updated', {
fulfillment_id: fulfillment.id,
tracking_number,
status,
});
res.sendStatus(200);
});
export default router;
Route is registered in src/api/index.ts:
import courierWebhookRouter from './routes/webhooks/courier';
export default (rootDirectory: string) => {
const router = Router();
router.use('/store', courierWebhookRouter);
return router;
};
Subscriber: customer notification
// src/subscribers/tracking-updated.ts
import { OrderService } from '@medusajs/medusa';
class TrackingUpdatedSubscriber {
private orderService: OrderService;
constructor({ orderService, eventBusService }) {
this.orderService = orderService;
eventBusService.subscribe(
'fulfillment.tracking_updated',
this.handleTrackingUpdated.bind(this)
);
}
async handleTrackingUpdated({ fulfillment_id, tracking_number, status }) {
// Send email via Notification Provider
// or update order metadata
console.log(`Fulfillment ${fulfillment_id}: ${tracking_number} -> ${status}`);
}
}
export default TrackingUpdatedSubscriber;
Custom ShippingOption in Admin
After registering the provider, ShippingOption is created in admin via UI or API:
curl -X POST http://localhost:9000/admin/shipping-options \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "MyCourier Standard",
"region_id": "reg_01HXXX",
"provider_id": "my-courier",
"data": { "id": "my-courier-standard" },
"price_type": "calculated",
"requirements": [
{ "type": "max_subtotal", "amount": 100000 }
]
}'
price_type: "calculated" means the price is determined via provider's calculatePrice(), not fixed.
Implementation timeline
Basic provider with tariff calculation and shipment creation: 2–3 days. Adding webhook handler, notification subscriber, and status updates: plus 1–2 days. Full npm package with configuration, tests, and support for Medusa v1 and v2: 5–7 days.







