Skip to main content

Shipping Provider Development

A shipping provider answers one question at checkout: given this cart and this destination, which shipping methods can I offer and what do they cost?

Providers are registered in memory at bootstrap, exactly like payment methods and email services. EverShop ships one built-in provider — core — backed by admin-configured rates. Everything a third-party integration (USPS, FedEx, EasyPost, Shippo) needs is the same public contract the core provider uses.

Breaking change in 2.2

The flat Zone → Method → Rate model is gone. The shipping_method and shipping_zone_method tables were dropped by modules/checkout/migration/Version-1.0.9.ts, along with cart.shipping_method, cart.shipping_method_name, order.shipping_method, order.shipping_method_name and shipping_zone.country. The calculate_api HTTP callback escape hatch is gone too — writing a provider replaces it. See Migrating from the pre-2.2 model.

The model

shipping_zone ──< shipping_zone_country      (multi-country zones)
│ ──< shipping_zone_province

└──< shipping_zone_provider ──> (registered provider, by code)

└── config: jsonb (per-zone provider config)

A zone is a geographic region. Providers attach to zones via shipping_zone_provider rows. Attachment carries a soft provider_code varchar — not a foreign key — so uninstalling a provider extension leaves inert orphan rows rather than a broken FK.

There is no shipping_provider table and no global enable/disable flag. The in-memory registry is the only source of truth: installed means enabled. Secrets and account credentials belong in process.env, read by the extension itself.

Methods are not modeled at the platform level. Each provider owns its own method storage — a table, an upstream API, or nothing at all — and synthesizes methods at runtime inside getMethods. The core provider happens to use two internal tables (core_shipping_method, core_shipping_method_rate); yours does not have to.

Registering a provider

Call registerShippingProvider from your module's bootstrap.ts:

extensions/my-shipping/src/bootstrap.ts
import { registerShippingProvider } from '@evershop/evershop/checkout/services';
import type {
ShippingContext,
ShippingMethod
} from '@evershop/evershop/types/shippingProvider';

export default async () => {
registerShippingProvider({
code: 'flat_express',
name: 'Flat Express',
description: 'Single flat rate with a free-shipping threshold.',
async getMethods(ctx: ShippingContext): Promise<ShippingMethod[]> {
return [
{
code: 'FLAT_EXPRESS_STANDARD',
name: 'Express (2-3 days)',
cost: ctx.totalValue >= 100 ? 0 : 12.5
}
];
}
});
};

Registration throws

Two failure modes are eager and loud, both by design:

// 1. Duplicate code — a second extension claiming the same code.
registerShippingProvider({ code: 'core', name: 'Mine', getMethods });
// Error: Shipping provider "core" is already registered. Each provider must
// have a unique code across the system.

// 2. Called after bootstrap — e.g. from a middleware or a request handler.
// The registry is locked in bin/lib/startUp.js right after every module's
// bootstrap runs (lockHooks(); lockRegistry(); lockCarrierRegistry()).
// addProcessor throws on any later registration.

registerShippingProvider also validates the shape synchronously and throws if code or name is missing or non-string, or if getMethods is not a function.

Implementation: modules/checkout/services/shipping/registry.ts.

Reading the registry

import {
getAllShippingProviders,
getShippingProvider
} from '@evershop/evershop/checkout/services';

const all = await getAllShippingProviders(); // ShippingProvider[]
const usps = await getShippingProvider('usps'); // ShippingProvider | undefined

Both are async — they resolve through the registry's getValue machinery. Order matches registration order, which follows alphabetical module load order.

The ShippingProvider contract

Defined in src/types/shippingProvider.ts, importable from @evershop/evershop/types/shippingProvider.

export interface ShippingProvider {
code: string;
name: string;
description?: string;
zoneConfigFields?: ZoneConfigField[];

getMethods(ctx: ShippingContext): Promise<ShippingMethod[]>;

validateMethod?(
ctx: ShippingContext,
methodCode: string
): Promise<ShippingMethod | null>;

quoteTtlSeconds?: number;
quoteTimeoutMs?: number;
}
FieldRequiredMeaning
codeYesUnique across the system. Persisted on shipping_zone_provider.provider_code and on shipping_method_data.provider_code. Never change it after a release — stored carts and orders reference it.
nameYesDisplay name in the admin provider list and Attach Provider dialog.
descriptionNoOne-liner shown next to the name in admin.
zoneConfigFieldsNoDeclares the per-zone configuration form. Values land in shipping_zone_provider.config and come back as ctx.zoneConfig.
getMethodsYesReturns the methods available for this cart + address + zone.
validateMethodNoCheap re-check of one previously selected method. Falls back to a getMethods scan.
quoteTtlSecondsNoQuote validity window. Omit for providers whose prices do not expire by time.
quoteTimeoutMsNoPer-provider budget for a single getMethods call. Defaults to 5000ms.

ShippingMethod

What getMethods returns.

export interface ShippingMethod {
code: string;
name: string;
cost: number;
taxClass?: string;
carrier?: string;
serviceCode?: string;
delivery?: DeliveryWindow;
metadata?: Record<string, unknown>;
}

export interface DeliveryWindow {
minBusinessDays?: number;
maxBusinessDays?: number;
/** ISO 8601 date string. */
estimatedDate?: string;
}

Rules that matter:

  • code must be stable per provider. The format is yours (core uses core_shipping_method.uuid; a carrier provider uses USPS_PRIORITY). Stability is what lets a stored shipping_method_data.method_code survive an address change.
  • cost must be in ctx.currency, tax-exclusive. ctx.currency is directive, not informational. If you cannot quote in that currency, return [] and log.
  • carrier and serviceCode are fulfillment metadata, threaded through to the shipment. serviceCode reaches CreateLabelInput.serviceCode at ship time — see Carrier Development.
  • metadata is opaque to core. Use it for upstream quote IDs / rate IDs; it is carried verbatim into the stored snapshot.

ShippingContext — the DTO principle

Providers never receive the Cart object. They never see CartItem, DataObject, or any class instance from the cart pipeline. The platform builds a plain, read-only DTO once per call and hands that over.

That decoupling is deliberate: your provider does not depend on internal data-loading or field-resolution machinery, so it stays portable and trivially unit-testable with a hand-written context literal.

export interface ShippingContext {
origin: Address;
destination: Address;
zone: ShippingZoneRow;
items: ShippingItem[];
totalWeight: number;
totalValue: number;
currency: string;
zoneConfig: Record<string, unknown>;
providerConfig: Record<string, unknown>;
}

export interface ShippingItem {
productId: number;
sku: string;
name: string;
qty: number;
/** Per-unit GOODS weight (no packaging tare). */
weight: number;
/** Tax-exclusive unit price. */
unitPrice: number;
/** qty × unitPrice. */
lineTotal: number;
noShippingRequired: boolean;
dimensions?: {
length: number;
width: number;
height: number;
unit: 'cm' | 'mm' | 'in';
};
}
FieldSource
origingetOriginAddress() — composed from the store settings. Always defined, possibly incomplete.
destinationThe cart's shipping address, or the country/province/postcode overrides passed to the rate calculator.
zoneThe shipping_zone row currently being evaluated. One call per matching (zone, provider) pair.
itemsserializeItems(cart) — plain DTOs, numeric fields coerced from PostgreSQL numeric strings.
totalWeight / totalValueCart total_weight and sub_total (tax-exclusive).
currencyCart currency, falling back to the store currency.
zoneConfigshipping_zone_provider.config for this attachment. {} when the provider declares no fields.
providerConfigAlways {}. Reserved — see below.

Built by modules/checkout/services/shipping/buildShippingContext.ts.

Configuration: zoneConfigFields, not JSON Schema

The original design called for two JSON Schemas — a global configSchema and a per-zone zoneConfigSchema. Neither shipped. What actually exists in 2.2 is one purpose-built field list:

Design conceptWhat exists in 2.2
configSchema (global provider config)Removed. There is no shipping_provider table and no global config form. Secrets and account settings read from process.env inside your extension. ctx.providerConfig is always {}; it survives in the type only so destructuring extensions keep compiling.
zoneConfigSchema (per-zone JSON Schema)Replaced by zoneConfigFields: ZoneConfigField[] — an ordered list of field descriptors, not JSON Schema. Values persist to shipping_zone_provider.config (JSONB) and arrive as ctx.zoneConfig.

The list is purpose-built because the admin renders it with EverShop's own form components, so the vocabulary is exactly what those components support. Field order is render order.

export interface ZoneConfigField {
/** Key in `shipping_zone_provider.config` (and `ctx.zoneConfig`). */
name: string;
type: 'text' | 'number' | 'textarea' | 'select' | 'toggle';
label: string;
placeholder?: string;
description?: string;
defaultValue?: string | number | boolean;
/** Required when type is 'select', ignored otherwise. */
options?: Array<{ value: string | number; label: string }>;
/** 'toggle' only. */
trueLabel?: string;
falseLabel?: string;
validation?: {
required?: string;
min?: { value: number; message: string };
max?: { value: number; message: string };
/** `value` is a regex SOURCE string; the renderer compiles it. */
pattern?: { value: string; message: string };
};
}
typeComponentStored value
textInputFieldstring
numberNumberFieldnumber
textareaTextareaFieldstring
selectSelectFieldvalue from options
toggleToggleFieldboolean

The descriptor list travels to the browser over the admin GraphQL type ShippingProvider.zoneConfigFields (typed JSON), which is why pattern.value is a regex source string rather than a RegExp — it must be JSON-serializable. ZoneConfigFields (in AttachProviderDialog.tsx) compiles it back with new RegExp(...) on the client.

A provider with no per-zone configuration simply omits the field, and the Attach Provider dialog shows an informational note instead of a form. The core provider does exactly that — its per-zone variation lives in core_shipping_method_rate instead.

getMethods vs validateMethod

getMethods is the listing call: "what can this cart have?" It runs once per matching (zone, provider) pair when the storefront asks for available methods.

validateMethod is the re-check call: "is the one method the customer already picked still valid, and at what price?" It runs on selection and again whenever the cart changes in a way that could move the price.

Why a separate hook exists: without it, re-checking one method costs a full rate-shop. The default fallback is exactly that —

const validate =
provider.validateMethod ??
(async (ctx, code) => {
const methods = await provider.getMethods(ctx);
return methods.find((m) => m.code === code) ?? null;
});

— which is correct but wasteful when the upstream API exposes a single-service quote endpoint. Implement validateMethod when your carrier has one. Return null to mean no longer available; the customer gets a clear error and must reselect.

Both calls must be safe to repeat with the same inputs. Results are memoized per request and cached on the cart by fingerprint, but never assume a call happens exactly once.

The orchestrator, timeouts, and failure isolation

getAvailableShippingMethods(cartId, country?, province?, postcode?) in modules/checkout/services/getAvailableShippingMethods.ts drives listing:

  1. Resolve every zone covering the destination. Overlapping coverage is allowed — all matching zones contribute.
  2. Load enabled shipping_zone_provider rows for those zones.
  3. Cross-reference each attachment's provider_code against the registry. Attachments whose provider is not registered are silently skipped — an uninstalled extension leaves inert rows, not errors.
  4. Fan out over every (zone, provider) pair in parallel with Promise.allSettled, each wrapped in a timeout.
  5. Dedupe by (providerCode, method.code), first occurrence wins.
  6. Sort by cost ascending.

The per-provider timeout is 5000ms by default, overridable per provider:

const methods = await withTimeout(
provider.getMethods(ctx),
provider.quoteTimeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS, // 5000
`Provider ${provider.code} (zone ${zone.shipping_zone_id})`
);

A rejected or timed-out provider is logged and skipped. One broken provider never fails the whole list — the customer just sees fewer options. This is also why a provider that needs a store origin address should throw a descriptive error rather than silently mis-quoting:

async getMethods(ctx) {
if (!ctx.origin?.country || !ctx.origin?.postcode) {
throw new Error(
'my-shipping requires a store origin address (country + postcode). ' +
'Set it under Settings → Store.'
);
}
// ...
}

Validation is per-provider on purpose. Core does not read origin, so a platform-wide origin requirement would break Core-only stores.

Set quoteTimeoutMs above the default only when the upstream is structurally slower — aggregators that rate-shop every connected carrier in one call can legitimately need 10-20 seconds. Keep it as low as is honest: this budget is checkout-blocking time whenever the quote is not served from cache.

The origin address

import { getOriginAddress } from '@evershop/evershop/checkout/services';

const origin = await getOriginAddress(); // Promise<Address>

There is no dedicated shop.origin_address setting. getOriginAddress composes the shop's ship-from address from the existing store settings — getStoreCountry, getStoreProvince, getStoreCity, getStoreAddress, getStorePostalCode — and returns:

{ country, province, city, address_1, postcode }

It always returns a defined object, possibly with null fields. Providers that need specific fields validate them themselves.

What gets stored: shipping_method_data

Both cart and order carry a single symmetric JSONB column, shipping_method_data. There are no flat method columns any more.

export interface ShippingMethodData {
provider_code: string;
method_code: string;
snapshot: ShippingMethodSnapshot; // === ShippingMethod, copied verbatim
/** Cart-only. */
fingerprint?: string;
/** Cart-only. ISO 8601. */
quotedAt?: string;
}
{
"provider_code": "usps",
"method_code": "USPS_PRIORITY",
"snapshot": {
"code": "USPS_PRIORITY",
"name": "USPS Priority Mail",
"cost": 8.4,
"carrier": "USPS",
"delivery": { "minBusinessDays": 1, "maxBusinessDays": 3 }
},
"fingerprint": "…",
"quotedAt": "2026-08-11T09:15:00.000Z"
}

Snapshot semantics. The snapshot is the provider's ShippingMethod copied verbatim at selection time (or at the last successful revalidation). It exists so the cart and the order stay meaningful even if the provider goes offline, changes its prices, or removes the method entirely. shipping_fee_draft reads snapshot.cost — no DB joins, no HTTP callbacks. Orders keep the snapshot forever and never recompute.

Fingerprint semantics. The fingerprint is a hash of the cart state that could change a quote: destination address fields, totalWeight, totalValue, and an items signature of (product_id, qty) pairs. It is cart-only — orders never carry one.

The rebuild rule is two-condition:

Customer actionFingerprint changes?Provider called?
Page reload within TTLNoNo — snapshot trusted
Page reload after quoteTtlSeconds elapsedNoYes, once
Add/remove item, change qtyYesYes, once
Change shipping addressYesYes, once
Change shipping methodForcedYes, once

The store origin is deliberately not part of the fingerprint — it changes at admin-config time, not per cart mutation.

Declare quoteTtlSeconds only if your quotes genuinely expire. Real-time carrier quotes typically use 900 (15 minutes) up to a few hours. Omit it for table-rate providers.

Selecting a method: resolveShippingQuote and ShippingQuoteError

The storefront submits a bare intent{ provider_code, method_code } — and the platform enriches it into the full stored value.

import {
setShippingMethod,
resolveShippingQuote,
ShippingQuoteError
} from '@evershop/evershop/checkout/services';

try {
await setShippingMethod(cart, {
provider_code: 'flat_express',
method_code: 'FLAT_EXPRESS_STANDARD'
});
} catch (e) {
if (e instanceof ShippingQuoteError) {
// User-facing: no provider, no zone, method no longer applies.
}
throw e;
}

setShippingMethod calls resolveShippingQuote(cart, intent) and then writes the enriched object with cart.setData('shipping_method_data', enriched).

warning

Never call cart.setData('shipping_method_data', ...) with a bare intent. Doing so stores a value with no snapshot and no fingerprint, which breaks the shipping fee, the tax fields, and the cache-by-fingerprint path on every subsequent rebuild. Always go through setShippingMethod.

resolveShippingQuote is shared by the API handler path and the cart field-resolver rebuild path. It:

  1. Rejects a missing provider_code / method_code.
  2. Looks up the provider in the registry.
  3. Requires a shipping address with a country.
  4. Resolves zones for that address.
  5. Iterates the zones in resolution order; for each, loads the enabled shipping_zone_provider attachment, builds a ShippingContext, and calls validateMethod. First zone where the method still validates wins. Per-zone errors are logged and the loop continues.
  6. Returns { provider_code, method_code, snapshot, fingerprint, quotedAt }.

It throws ShippingQuoteError for every condition that should surface to a human:

MessageCause
Missing provider_code or method_codeMalformed intent.
Shipping provider "X" is not registeredExtension not installed, or code typo.
Shipping address is requiredCart has no shipping country yet.
We do not ship to this addressNo zone covers the destination.
Selected shipping method is no longer availableEvery candidate zone's validateMethod returned null.

Any other error propagates as-is.

The REST endpoint is POST /api/carts/:cart_id/shippingMethods and both fields are now required:

{
"provider_code": "flat_express",
"method_code": "FLAT_EXPRESS_STANDARD"
}

Worked example: a complete custom provider

A weight-banded provider with a per-zone surcharge, a real validateMethod, and per-zone configuration.

extensions/regional-courier/src/bootstrap.ts
import { registerShippingProvider } from '@evershop/evershop/checkout/services';
import { error } from '@evershop/evershop/lib/log';
import type {
ShippingContext,
ShippingMethod,
ShippingProvider
} from '@evershop/evershop/types/shippingProvider';

const BANDS = [
{ maxKg: 1, cost: 4.9 },
{ maxKg: 5, cost: 8.9 },
{ maxKg: 20, cost: 16.9 }
];

function bandFor(weightKg: number): number | null {
const band = BANDS.find((b) => weightKg <= b.maxKg);
return band ? band.cost : null;
}

function surchargeOf(ctx: ShippingContext): number {
const raw = ctx.zoneConfig.surcharge;
const n = typeof raw === 'number' ? raw : parseFloat(String(raw ?? '0'));
return Number.isFinite(n) ? n : 0;
}

function buildMethod(ctx: ShippingContext): ShippingMethod | null {
const base = bandFor(ctx.totalWeight);
if (base === null) {
// Over 20kg — out of scope for this courier.
return null;
}
const expedited = ctx.zoneConfig.expedited === true;
return {
code: expedited ? 'RC_EXPRESS' : 'RC_STANDARD',
name: expedited ? 'Regional Courier Express' : 'Regional Courier',
cost: parseFloat((base + surchargeOf(ctx)).toFixed(2)),
carrier: 'regional-courier',
serviceCode: expedited ? 'RC_EXP' : 'RC_STD',
delivery: expedited
? { minBusinessDays: 1, maxBusinessDays: 2 }
: { minBusinessDays: 2, maxBusinessDays: 5 },
metadata: { band: base }
};
}

const regionalCourier: ShippingProvider = {
code: 'regional_courier',
name: 'Regional Courier',
description: 'Weight-banded domestic delivery with a per-zone surcharge.',

zoneConfigFields: [
{
name: 'surcharge',
type: 'number',
label: 'Zone surcharge',
placeholder: '0.00',
description: 'Flat amount added to every quote in this zone.',
defaultValue: 0,
validation: {
min: { value: 0, message: 'Surcharge cannot be negative' }
}
},
{
name: 'expedited',
type: 'toggle',
label: 'Expedited service',
description: 'Quote the 1-2 day service instead of the standard one.',
defaultValue: false,
trueLabel: 'Express',
falseLabel: 'Standard'
},
{
name: 'contractId',
type: 'text',
label: 'Contract ID',
description: 'Courier contract that applies in this zone.',
validation: {
required: 'Contract ID is required',
pattern: {
value: '^RC-[0-9]{6}$',
message: 'Expected format RC-123456'
}
}
}
],

// Quotes are computed locally from static bands — they never go stale by
// time, so no quoteTtlSeconds. The default 5s timeout is plenty.
async getMethods(ctx: ShippingContext): Promise<ShippingMethod[]> {
if (!ctx.origin?.country) {
throw new Error(
'regional_courier requires a store origin country. Set it under Settings → Store.'
);
}
// Domestic only.
if (ctx.destination?.country !== ctx.origin.country) return [];

// Quote in the cart's currency or not at all.
if (ctx.currency !== 'EUR') {
error(
new Error(`regional_courier cannot quote in ${ctx.currency} — skipping`)
);
return [];
}

// Ignore digital lines entirely.
const physical = ctx.items.filter((i) => !i.noShippingRequired);
if (physical.length === 0) return [];

const method = buildMethod(ctx);
return method ? [method] : [];
},

// Cheap single-method re-check — no rate-shop, just rebuild the one method.
async validateMethod(
ctx: ShippingContext,
methodCode: string
): Promise<ShippingMethod | null> {
const method = buildMethod(ctx);
return method && method.code === methodCode ? method : null;
}
};

export default async () => {
registerShippingProvider(regionalCourier);
};

Because the provider only ever sees a plain ShippingContext, testing it needs no database and no cart:

extensions/regional-courier/tests/regionalCourier.test.ts
const ctx = {
origin: { country: 'DE', postcode: '10115' },
destination: { country: 'DE', postcode: '80331' },
zone: { shipping_zone_id: 1, uuid: 'z1', name: 'Germany' },
items: [
{
productId: 1,
sku: 'SKU-1',
name: 'Mug',
qty: 2,
weight: 0.4,
unitPrice: 12,
lineTotal: 24,
noShippingRequired: false
}
],
totalWeight: 0.8,
totalValue: 24,
currency: 'EUR',
zoneConfig: { surcharge: 1.5, expedited: false, contractId: 'RC-123456' },
providerConfig: {}
} as any;

// → [{ code: 'RC_STANDARD', cost: 6.4, ... }]

Once installed, the provider appears in Settings → Shipping and an admin attaches it to a zone; the three zoneConfigFields render in the Attach Provider dialog, and their values arrive as ctx.zoneConfig on every call.

Migrating from the pre-2.2 model

Before 2.22.2
shipping_method tableDropped. Migrated into core_shipping_method, which is internal to the core provider. UUIDs were preserved so legacy method codes still resolve.
shipping_zone_method tableDropped. Migrated into core_shipping_method_rate, also core-provider-internal.
shipping_zone_method.calculate_apiGone, not migrated. The HTTP-callback-into-yourself escape hatch is replaced by writing a real provider with getMethods.
Zone → Method attachmentZone → Provider attachment (shipping_zone_provider). Methods are provider-owned; the platform does not model them.
shipping_zone.country (one country per zone)Dropped. Replaced by the shipping_zone_country junction — a zone can cover many countries. shipping_zone_province gained a country column, and its unique constraint became (zone_id, country, province) so a province can belong to more than one zone.
cart.shipping_method, cart.shipping_method_nameDropped. Replaced by cart.shipping_method_data (JSONB).
order.shipping_method, order.shipping_method_nameDropped. Replaced by order.shipping_method_data (JSONB). Existing orders were backfilled with provider_code: 'core' and snapshot.cost from shipping_fee_excl_tax. Legacy snapshots have no carrier and no delivery — read them defensively.
cart.shipping_zone_idDropped. It was dead — nothing populated it, and the zone is re-derived per call.
Cart field shipping_methodCart field shipping_method_data. The shipping_method_name cart field is removed — read snapshot.name.
POST /api/carts/:cart_id/shippingMethods body { method_code }Body { provider_code, method_code } — both required. There is no silent default to core.
GraphQL Cart.shippingMethod / Order.shippingMethodRemoved. Use shippingMethodData. shippingMethodName is kept as a convenience field resolved from shippingMethodData.snapshot.name.
GraphQL AvailableShippingMethodGains providerCode (required in the selection payload), plus carrier, serviceCode and delivery. id remains an alias of code for back-compat.
Price/weight tier bounds [min, max]Half-open [min, max). A cart at exactly the boundary no longer matches two adjacent tiers.
GraphQL ShippingZone.shipping_zone_idshippingZoneId (camelCase, matching the rest of the schema).

If you maintained an extension that inserted rows into shipping_method / shipping_zone_method, or that served a calculate_api endpoint, the port is to implement ShippingProvider.getMethods and register it at bootstrap. Your own storage, if you need any, is entirely your own concern.

See Also



Support us


EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.