Medusa Engineering
Custom Shipping Rules in Medusa: The Multi-Carrier Pattern That Scales Past Flat-Rate
When D2C brands outgrow flat-rate shipping, they need inventory-aware location routing, weight-based carrier selection, and real-time rate shopping. Here's the Medusa shipping service pattern that handles complex fulfillment rules without losing sales to API timeouts.

Every D2C brand hits the same shipping wall: flat rates work until you have multiple warehouses, varied product weights, and customers who expect accurate delivery costs.
Flat-rate shipping breaks the moment you add complexity. A second warehouse location. Products that weigh 50g versus 5kg. International customers who need accurate duties. Expedited shipping that actually costs what you charge. The CFO sees margin erosion; the CTO sees checkout abandonment when shipping costs surprise customers at the last step.
We have built custom shipping calculation layers for eight Medusa projects in the past 18 months. The pattern is the same every time: inventory-aware location routing, weight and dimension bands that feed carrier APIs, and real-time rate shopping across FedEx, UPS, and DHL. The implementation saves 2-4% of gross revenue in the first quarter by eliminating shipping subsidies and reducing checkout drop-off.
When Flat-Rate Shipping Becomes a Profit Leak
Flat-rate shipping works for single-location brands with uniform product weights. It breaks when fulfillment complexity grows faster than the rate structure can accommodate. We see the breaking point consistently around 500 orders per month with multiple SKU weight classes.
Multiple warehouse locations where shipping from the closest facility saves $8-15 per order
Product weight variance >10x (jewelry + furniture, supplements + equipment)
International shipping where flat rates either lose money or kill conversion
Expedited options where customers expect accuracy within $2-3 of actual cost
B2B orders where shipping cost affects purchase order approval workflows
The financial impact shows up in two places: margin compression when you undercharge for heavy/distant shipments, and conversion loss when you overcharge for light/local ones. A D2C furniture brand we worked with was losing $12 per order on cross-country shipments while charging $35 flat-rate shipping that killed 18% of local checkout conversions.
Medusa's Shipping Architecture: Where Custom Logic Lives
Medusa separates shipping into three layers: shipping options (what the customer sees), shipping methods (how rates get calculated), and fulfillment providers (what actually ships the order). Custom shipping calculations live in the shipping method layer, implemented as a custom shipping service.
// src/services/custom-shipping.ts
import { AbstractShippingService } from "@medusajs/medusa"
import { ShippingMethodData, ShippingOptionData } from "@medusajs/types"
class CustomShippingService extends AbstractShippingService {
static identifier = "custom-shipping"
async calculatePrice(
optionData: ShippingOptionData,
data: ShippingMethodData,
cart: Cart
): Promise<number> {
// Inventory location routing
const fulfillmentLocation = await this.findOptimalLocation(cart)
// Weight and dimension calculation
const packageSpecs = await this.calculatePackageSpecs(cart.items)
// Multi-carrier rate shopping
const rates = await this.fetchCarrierRates(
fulfillmentLocation,
cart.shipping_address,
packageSpecs,
optionData.service_level
)
return this.selectOptimalRate(rates)
}
async canCalculate(data: ShippingMethodData): Promise<boolean> {
// Validate we have required data for calculation
return !!(data.cart?.shipping_address && data.cart?.items?.length)
}
}The service hooks into Medusa's shipping calculation flow during checkout. When a customer selects a shipping option, Medusa calls your calculatePrice method with the cart contents and shipping address. The method returns a price in the store's base currency.
Inventory-Aware Location Routing: Shipping From the Right Warehouse
Location routing is the highest-impact optimization in multi-warehouse fulfillment. Shipping a 2kg package from Los Angeles to San Francisco costs $8-12. The same package from New York costs $18-25. The routing logic needs to balance shipping cost against inventory availability.
async findOptimalLocation(cart: Cart): Promise<Location> {
const cartItems = cart.items
const destination = cart.shipping_address
// Get locations with sufficient inventory for all items
const viableLocations = await this.inventoryService
.getLocationsWithInventory(cartItems)
if (viableLocations.length === 1) {
return viableLocations[0]
}
// Calculate shipping cost from each viable location
const locationCosts = await Promise.all(
viableLocations.map(async (location) => {
const estimatedCost = await this.estimateShippingCost(
location,
destination,
this.calculateTotalWeight(cartItems)
)
return { location, cost: estimatedCost }
})
)
// Return location with lowest shipping cost
return locationCosts
.sort((a, b) => a.cost - b.cost)[0]
.location
}The routing decision affects both shipping cost and delivery time. A split shipment might cost $15 more but deliver three days faster. The business logic depends on your customer base: B2B buyers often prefer consolidated shipments, while D2C customers lean toward speed.
Weight and Dimension Bands: The Calculation Layer Most Teams Get Wrong
Carrier APIs price by dimensional weight (DIM weight): the greater of actual weight or volumetric weight based on package dimensions. Most custom shipping implementations only consider actual weight, which undercharges for bulky, light items and creates margin leaks.
interface PackageSpecs {
actualWeight: number
dimensions: { length: number; width: number; height: number }
dimWeight: number
billableWeight: number
}
async calculatePackageSpecs(items: LineItem[]): Promise<PackageSpecs> {
// Sum actual weights
const actualWeight = items.reduce((total, item) => {
const productWeight = item.variant.weight || 0
return total + (productWeight * item.quantity)
}, 0)
// Calculate optimal packing dimensions
const dimensions = await this.packingService.calculateDimensions(items)
// Calculate dimensional weight (standard 139 divisor for domestic US)
const dimWeight = (
dimensions.length * dimensions.width * dimensions.height
) / 139
return {
actualWeight,
dimensions,
dimWeight,
billableWeight: Math.max(actualWeight, dimWeight)
}
}The packing calculation is where most implementations get tricky. You need to model how your fulfillment team actually packs orders: single items in manufacturer packaging, multiple items in a box, or items that require special handling. We maintain packing rules per product category and update them based on fulfillment feedback.
Multi-Carrier Rate Shopping: FedEx vs UPS vs DHL in One Calculation Cycle
Rate shopping across carriers can save 15-30% on shipping costs, but it adds API complexity and latency. The pattern is to call all carrier APIs in parallel, then select the best rate based on your business rules (cheapest, fastest, or a weighted score).
async fetchCarrierRates(
origin: Location,
destination: Address,
packageSpecs: PackageSpecs,
serviceLevel: string
): Promise<CarrierRate[]> {
const ratePromises = [
this.fedexService.getRates(origin, destination, packageSpecs, serviceLevel),
this.upsService.getRates(origin, destination, packageSpecs, serviceLevel),
this.dhlService.getRates(origin, destination, packageSpecs, serviceLevel)
]
// Fetch all rates in parallel with timeout
const results = await Promise.allSettled(
ratePromises.map(promise =>
Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 3000)
)
])
)
)
// Extract successful rates
const rates = results
.filter((result): result is PromiseFulfilledResult<CarrierRate[]> =>
result.status === 'fulfilled'
)
.flatMap(result => result.value)
.filter(rate => rate.serviceLevel === serviceLevel)
return rates
}The 3-second timeout is critical. Carrier APIs can be slow, especially during peak shipping seasons. If rate shopping takes longer than 3-4 seconds, checkout conversion drops measurably. The fallback strategy uses cached rates or a flat-rate backup.
Caching Carrier Responses Without Stale Rates
Carrier rates change throughout the day based on fuel costs, demand, and routing efficiency. Caching rates for too long gives customers stale prices that create fulfillment losses when the actual shipping cost is higher. The sweet spot is 15-minute cache windows with cache keys that include package weight and destination zone.
async getCachedRate(
cacheKey: string,
rateFetcher: () => Promise<CarrierRate[]>
): Promise<CarrierRate[]> {
const cached = await this.cacheService.get(cacheKey)
if (cached && !this.isStale(cached.timestamp)) {
return cached.rates
}
const rates = await rateFetcher()
await this.cacheService.set(cacheKey, {
rates,
timestamp: Date.now()
}, 900) // 15 minute TTL
return rates
}
private buildCacheKey(
origin: string,
destinationZone: string,
weightBand: string,
serviceLevel: string
): string {
return `shipping:${origin}:${destinationZone}:${weightBand}:${serviceLevel}`
}
private isStale(timestamp: number): boolean {
return Date.now() - timestamp > 900000 // 15 minutes
}Weight bands are essential for effective caching. Instead of caching rates for exact weights (2.3kg, 2.4kg, 2.5kg), group weights into bands (2-3kg, 3-4kg, 4-5kg). This increases cache hit rates while maintaining pricing accuracy within acceptable ranges.
Error Handling for Carrier API Failures: Fallback Rates That Don't Lose Sales
Carrier APIs fail. FedEx goes down during peak season. UPS rate services timeout during high traffic. DHL returns errors for certain destination countries. The shipping calculation must handle these failures gracefully without blocking checkout.
async selectOptimalRate(rates: CarrierRate[]): Promise<number> {
if (rates.length === 0) {
// Fallback to distance-based calculation
return this.calculateFallbackRate()
}
// Sort by total cost (rate + handling fee)
const sortedRates = rates.sort((a, b) =>
(a.rate + a.handlingFee) - (b.rate + b.handlingFee)
)
const bestRate = sortedRates[0]
// Add margin and round to avoid pricing psychology issues
const finalRate = Math.ceil((bestRate.rate + bestRate.handlingFee) * 1.1)
return finalRate
}
private async calculateFallbackRate(): Promise<number> {
// Use distance-based calculation when APIs fail
const distance = await this.calculateDistance(
this.originAddress,
this.destinationAddress
)
const baseRate = Math.max(8.99, distance * 0.12)
return Math.ceil(baseRate)
}The fallback calculation uses distance-based pricing that approximates carrier rates without API dependencies. It is not as accurate as real-time rates, but it keeps checkout flowing when APIs fail. The 10% margin buffer accounts for rate fluctuations and handling costs.
Testing Shipping Calculations: The Test Cases That Catch Rounding Errors in Production
Shipping calculation bugs are expensive. A rounding error that undercharges shipping by $2 per order costs $1000/month at 500 orders. The test suite needs to cover edge cases that only appear with real order data: zero-weight digital products, international addresses with long postal codes, and dimensional weight calculations that hit carrier thresholds.
describe('CustomShippingService', () => {
it('handles zero-weight digital products', async () => {
const cart = createTestCart([
{ weight: 0, isDigital: true },
{ weight: 1.2, isDigital: false }
])
const rate = await shippingService.calculatePrice(shippingOption, {}, cart)
// Should only charge for physical items
expect(rate).toBeGreaterThan(0)
})
it('applies dimensional weight when volume exceeds weight', async () => {
const cart = createTestCart([
{
weight: 0.5, // Light item
dimensions: { length: 24, width: 18, height: 12 } // Bulky
}
])
const rate = await shippingService.calculatePrice(shippingOption, {}, cart)
// Should charge based on dimensional weight (24*18*12/139 = ~37 lbs)
expect(rate).toBeGreaterThan(25) // Higher than 0.5 lb rate
})
it('falls back gracefully when all carriers timeout', async () => {
// Mock all carrier services to timeout
jest.spyOn(fedexService, 'getRates').mockRejectedValue(new Error('Timeout'))
jest.spyOn(upsService, 'getRates').mockRejectedValue(new Error('Timeout'))
const rate = await shippingService.calculatePrice(shippingOption, {}, cart)
expect(rate).toBeGreaterThan(0) // Should return fallback rate
})
})We hit a production bug where international postal codes longer than 10 characters broke the UPS API integration. The order would fail at shipping calculation, but the customer had already entered payment details. Testing with real international address formats catches these edge cases before they hit production.
Custom shipping calculations pay for themselves quickly when done right. The pattern we ship handles inventory routing, multi-carrier rate shopping, and graceful API failures. It typically reduces shipping costs by 12-18% while improving checkout conversion through accurate pricing.
Building complex fulfillment rules for your Medusa store?Tell us what you are shippingand we will walk through the shipping calculation pattern that fits your inventory setup.
On every Medusa project with multi-location inventory or varied product weights, we ship this shipping service pattern by default. It has eliminated margin leaks and API-failure checkout blocks on every implementation so far. The 15-minute cache strategy and fallback rate calculation are the two pieces that separate production-ready shipping from a proof-of-concept.
// After the call
Questions operators ask next
How long does it take to implement custom shipping calculations in Medusa?
Basic multi-carrier rate shopping takes 2-3 weeks. Adding inventory-aware location routing and dimensional weight calculations extends it to 4-5 weeks. The timeline depends on how many carrier APIs you need to integrate and whether you have existing packing data for dimensional weight calculations.
What happens when carrier APIs are slow or down during checkout?
The service includes 3-second timeouts and fallback rate calculations based on distance and weight. If all carrier APIs fail, customers see distance-based rates that approximate real shipping costs. This prevents checkout failures during carrier API outages.
Does this pattern work with Medusa's fulfillment providers?
Yes, the custom shipping service calculates rates during checkout, while fulfillment providers handle the actual shipping after order placement. You can use this for rate calculation with any fulfillment provider (ShipStation, Easyship, or custom integrations).
How accurate are the cached shipping rates compared to real-time rates?
With 15-minute cache windows and weight-band grouping, cached rates stay within $1-2 of real-time rates for 95% of shipments. The cache hit rate is typically 60-70%, which reduces API calls and improves checkout speed without meaningful accuracy loss.
Can this handle international shipping with duties and taxes?
The rate calculation handles carrier shipping costs. For duties and taxes, you need to integrate with services like Avalara or TaxJar, or use carrier-calculated duties through FedEx International or UPS Worldwide. The pattern supports both approaches through the rate selection logic.
What carrier APIs work best for multi-carrier rate shopping?
FedEx, UPS, and USPS have the most reliable rate APIs for US domestic shipping. For international, DHL and FedEx International are most consistent. Avoid regional carriers for rate shopping unless they are your primary fulfillment method — their APIs tend to be less reliable and slower.
Pull quote
The moment you add a second warehouse or ship internationally, flat-rate shipping becomes a profit leak. Real-time carrier rates with location routing pays for itself in month one.