Delivery Services Integration in Medusa.js

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

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.