Skip to main content

delivery_methods

The platform's shipping-method framework (Metoda Dostawy). The module hosts a pluggable adapter registry over the delivery-method catalog, the delivery-side twin of payment_methods. The first-class Shipment record and its lifecycle live in the sibling shipments module. Carrier integration modules register adapters into this framework. Which ones your instance has depends on what is installed, so they are named rather than linked here: a link to a sibling page is a broken link in every instance that does not install that module, which is what onBrokenLinks: 'throw' says about it.

A delivery method is never hard-coded: the platform discovers methods from whichever adapter modules are installed and enabled. Enabling a recognised shipping-method adapter module auto-creates a configurable delivery_methods row visible at /delivery-methods.

Public surface​

Admin routes are gated by delivery_methods:read (reads) and delivery_methods:write (mutations) — the module's own codes since 2026-08-28. They were catalog:read / catalog:write until then, which meant whoever could edit a product could also decide how the shop ships, and delete a delivery method outright. A role that was relying on the catalogue codes for this screen has to be granted the new ones on /admin-roles; nothing grants them automatically, deliberately.

Verb + PathAudienceGatePurpose
GET /api/v1/delivery-methodsanon—Eligible methods for the storefront checkout (active ∩ sales-channel ∩ Organization allow-list ∩ adapter registered ∩ validateUseOnStorefront)
GET /api/v1/admin/delivery-methodsadmindelivery_methods:readFull list with adapter, status mappings, sales channels, renderer key
PUT /api/v1/admin/delivery-methods/:codeadmindelivery_methods:writeUpsert by code (name, cost/price, status, statusOnSuccess/statusOnFailure, sales channels)
DELETE /api/v1/admin/delivery-methods/:idadmindelivery_methods:writeHard delete (guarded: rejected with 409 when a Shipment references the method — set status inactive instead)

The admin status selectors read their options from GET /api/v1/admin/order-statuses (owned by the payment-methods admin routes; shared OrderStatusRegistry). That route is gated payment_methods:read or delivery_methods:read — an any-of over the two editors that read it, so the code that opens this screen also opens its status selectors.

Entry fields​

A delivery_methods row carries: code (unique), adapter (registry key), per-language name (default + overrides), cost + currency (the price surcharge added to the order total), status (active/inactive), statusOnSuccess / statusOnFailure (Order-status references applied when shipment generation succeeds/fails). There is no statusOnPending and no kind column — a shipping method is identified by its adapter alone.

statusOnSuccess / statusOnFailure reference Order statuses resolved through the OrderStatusRegistry port (enum-backed by the order status enum until the Orders module ships a configurable registry). Seed defaults: shipped / in_fulfilment.

Sales-channel scoping reuses the generic SalesChannelMembershipService ('delivery-method'); per-Organization availability reuses OrganizationRestrictionService ('delivery_method', opt-out blocklist).

How to build a shipping-method module​

A platform module is recognised as a shipping-method adapter iff it registers a ShippingAdapter in the process-wide shippingAdapterRegistry from its boot hook. No core change is required.

  1. Implement the ShippingAdapter contract (@endora-commerce/contracts):

    import type { ShippingAdapter } from '@endora-commerce/contracts';

    export const myCarrierAdapter: ShippingAdapter = {
    adapterKey: 'my_carrier',
    // Extra conditions per surface; return a constant true when none apply.
    validateUseOnStorefront: async () => true,
    validateUseOnAdmin: async () => true,
    validateUseInApi: async () => true,
    // order_created: may start generation; safe to leave as a no-op.
    onOrderCreated: async () => {},
    // shipment_created: begin generation, return the next action.
    onShipmentCreated: async () => ({ kind: 'pending' }),
    // receive_shipment: map the ingress to a success/failure outcome.
    onReceiveShipment: async (ctx) => ({
    result: 'success',
    externalReference: ctx.externalReference ?? null,
    }),
    // Optional renderer keys; absent ⇒ the platform default is used.
    renderers: { storefront: 'my_carrier', email: 'my_carrier.email' },
    };
  2. Contribute the adapter from the boot hook, naming the owning module, and ship the method row as a migration:

    import { shippingAdapterRegistry } from '.../delivery_methods/services/registry-singleton.js';

    ctx.onBoot(() => {
    shippingAdapterRegistry.register(myCarrierAdapter, 'my_carrier_module');
    });

    The owner id is what lets the registry skip the adapter while its module is absent, so a carrier an operator switches off stops being offered instead of being offered and failing — the same defect the payment twin had, fixed on both sides. The delivery_methods row itself is static reference data and belongs in your module's migration; DeliveryMethodReconciler remains available from an installHook for a row that must be created from code. No uninstall hook is needed to withdraw the adapter — a module that is not present is not enumerated.

    The skip does not answer for an order already placed on your method: a shipment can still be generated for it, and that shipment opens pending_manual naming your module rather than reading like one you accepted. You write no code for it — see When the registry is read below.

    Your hook pushes and returns. It does not check what is already in the table, does not check whether delivery_methods is present, and treats no absence as an error — because nothing reads the registry while modules are being composed. Boot hooks run whatever a module's effective state is; the enumeration answers presence, not the registration. A throw in a boot hook is not one adapter dropping out: runBootHooks re-throws it as ModuleCompositionError and index.ts turns that into process.exit(1), so the operator's next start dies over a switch they were entitled to use. Nor may a contributing hook probe effectiveState — the host already filters at enumeration, and a probe at the push would make switching your carrier back on require a restart. If your hook also does work (a reconcile, a Redis or Postgres write), split it in two first: the working half probes, the contributing half never does.

  3. Optional renderers — register custom renderers under the keys you declared:

    • Storefront: registerShippingMethodRenderer(key, fn) in storefront/lib/shipping-renderers/registry.tsx.
    • E-mail: registerShippingEmailRenderer(key, fn) in shipments/services/shipping-email-renderer.ts. When a renderer is missing for a surface, the platform default is used so the method always renders.
  4. Enable the module from the admin module-lifecycle screen → a configurable Delivery Method appears at /delivery-methods.

The two bundled offline reference adapters — manual_courier (Wysyłka własna) and personal_pickup (Odbiór osobisty) — need no external carrier and are the worked example of the full lifecycle.

When the registry is read​

The shippingAdapterRegistry is a process-wide singleton (delivery_methods/services/registry-singleton.ts): one table of adapters per process, however many times the platform is composed. Contributions are pushed into it once, during composition. Every read of it happens later, inside a request:

ReadWhereWhat an absent adapter means there
Storefront eligibilityGET /api/v1/delivery-methods → ShippingMethodEligibilityService.filterthe method is not offered
Admin upsert guardPUT /api/v1/admin/delivery-methods/:code → isRegisteredan explicitly supplied key nobody contributed is rejected (400); a contributed one whose owner is off is accepted, because the read is presence-blind on purpose
Order placementorders re-validates the chosen method, then fires onOrderCreateda method whose owner is off answers 503 MODULE_DISABLED; an unregistered one skips the hook
Shipment generationShipmentService.create → onShipmentCreatedthe adapter hook is skipped and the Shipment opens pending_manual naming the absent module — never plain pending, which would read as a shipment the carrier had accepted
Order-confirmation e-mailthe method's renderers.email keythe platform default renderer is used

Two things follow, and they are the reason this section exists rather than being left to be inferred. First, there is no order to get right between contributors: your adapter is visible to the first read whether it landed before or after anybody else's, so a boot hook has nothing to wait for and nothing to verify. Second, an absent or switched-off contributor is answered at the read, by the entry's recorded owner — never at the push. That is what makes this a contribution point rather than a gated port: the push is ungated on purpose, because gating it would turn one operator flip into a boot failure naming a module nobody touched.

The presence filter splits the surface by who is asking. get, resolve, list and isAvailable skip an entry whose owning module is not effectively present — a buyer is never offered a carrier that cannot take the parcel, and resolve raises the ordinary ModuleDisabledError. entry, ownerOf, isRegistered and listAll deliberately do not, because /delivery-methods has to keep showing the method and the reason it is unavailable: switching a module off is not uninstalling it.

absentOwnerFor(adapterKey) is the fifth reader and the only one that answers the question instead of exposing the table: it names the module that contributed the key and is not present, and null in every other case. It exists because get() collapses two situations an operator cannot act on identically — a key nobody ever contributed, and a key whose carrier module is switched off — and only the second one names something they can switch back on. shipments asks it to decide which state to open a Shipment in; the payment twin, GatewayRefundRegistry.absentOwnerFor, is the same reader for the same reason.

Lifecycle​

See shipments for the order_created → shipment_created → receive_shipment lifecycle, the Shipment entity, retries, and the order-status mapping.