A fulfillment (Spree::Fulfillment, prefix ful_, number F…) is one batch of items going to the customer by one delivery method from one stock location — a parcel, a download, or an order waiting at a pickup counter. A cart/order gets one fulfillment per (stock location × delivery profile × splitter output); the customer picks a delivery rate per fulfillment.
Coming from 5.x (Shipment, ShippingMethod, StockItem, shipment state machine)? Constant aliases exist until 6.1, but see spree-upgrade-5-to-6 — don't write new code against the old names.
The model graph
Cart / Order
└── Fulfillment (ful_) status: unfulfilled → fulfilled → delivered | canceled
├── stock_location (sloc_) where it ships from
├── FulfillmentItem (fi_) × n line_item + variant + quantity; status on_hand/backordered/shipped/returned
├── DeliveryRate (dr_) × n the options offered; one has selected: true
│ └── DeliveryMethod (dm_) what the customer picks ("Standard", "Express", "Collect in store")
├── Delivery (dlv_) × n each consignment/parcel: tracking_number, carrier, status
├── ShippingLabel (lbl_) × n postage bought (purchased) or recorded (uploaded)
└── TaxLine / Discount / Fee delivery money rows (see spree-order-totals)
Product ──> DeliveryProfile (fp_) how a product ships: DeliveryProfiles::Shipping | ::Digital ├── DeliveryOriginGroup (og_) × n a named set of stock locations ("EU warehouse") ├── DeliveryZone (dz_) × n where: DeliveryZoneMember (dzm_) country | state | postal_code └── DeliveryMethod × n belongs_to profile + origin group, optional zone ├── calculator how much (when priced internally) ├── rate_provider where the price comes from (default Internal) ├── fulfillment_provider how it's dispatched (Manual | Digital | Pickup | PickupPoint | carrier gem) └── DeliveryMethodRule (dmrule_) × n eligibility, AND-ed
PackageType (pkgtype_) box/envelope/carton/pallet/container — tare + dimensions for quotes
Things to internalize:
- **Every fulfillment has exactly one owner** — `cart_id` during checkout, `order_id` after placement. Read `fulfillment.owner`, never assume `.order`.
- **Delivery type comes from the method's fulfillment provider class**, not a string column: `delivery_method.digital?`, `.pickup?`, `.pickup_point?`, `.requires_address?`. The API exposes `fulfillment_type` (`shipping | digital | pickup | pickup_point`) on the fulfillment.
- **The delivery profile decides what a product can use.** A digital profile only accepts digital providers; a shipping profile accepts everything else. Origin groups narrow which locations may source the product — the coordinator never packs a product from a location its profile doesn't cover.
- `cart.delivery_step_required?` is `line_items.any? && !digital?` — an all-digital cart skips delivery. `shipping_address_required?` asks the selected methods (or the profiles, before a method is chosen) — a shipping profile offering only pickup needs no address.
- **Delivery zones are delivery-only.** Tax zones are separate (spree-taxes).
## Statuses
`Spree::Fulfillment` uses `has_status :unfulfilled, :fulfilled, :delivered, :canceled` — a string `status` column, no state machine. Statuses describe what *the merchant* did with the parcel and only move forward:
| Status | Meaning |
|---|---|
| `unfulfilled` | Still with you |
| `fulfilled` | Handed over (carrier, pickup counter, or download made available); `fulfilled_at` set |
| `delivered` | Customer has it; `delivered_at` set (from the carrier's scan, not webhook arrival) |
| `canceled` | Won't be sent. Final — there is no resume |
Payment/backorder readiness is **not** a status. It is checked by `Spree::Fulfillments::Fulfill` at the moment you ship, and returns a reason (`order_not_paid`, `backordered_units`, `insufficient_stock_on_hand`, `order_draft`). Staff pass `force: true` to ship against an unpaid invoice or past a backorder (never past a draft order). `can_fulfill?` / `can_cancel?` are phrased negatively ("not already gone out"), so custom pre-handover statuses stay fulfillable.
Carrier truth lives on each **`Spree::Delivery`** (`Spree::Delivery::STATUSES`: `pending pre_transit in_transit out_for_delivery available_for_pickup delivered return_to_sender failure unknown`) and never moves the fulfillment backwards. The fulfillment becomes `delivered` when every consignment has arrived. `tracking` / `tracking_url` on the fulfillment summarize the first delivery.
The order rolls this up into `order.fulfillment_status` (written only by `Spree::Orders::UpdateStatuses`): `unfulfilled`, `partial`, `fulfilled`, `delivered`, `backorder`, `canceled`. Don't write it yourself.
## Transitions are workflows
Never `update!(status: 'fulfilled')`. Call the workflow through its DI key so apps can swap it:
| Action | Workflow (`Spree.<key>`) | Hooks |
|---|---|---|
| Ship (whole or partial) | `fulfillment_fulfill_workflow` → `Spree::Fulfillments::Fulfill` | `validate`, `after_fulfill` |
| Confirm receipt | `fulfillment_mark_delivered_workflow` → `Fulfillments::MarkDelivered` | `validate`, `after_mark_delivered` |
| Cancel | `fulfillment_cancel_workflow` → `Fulfillments::Cancel` | `validate`, `after_cancel` |
| Manual/3PL fulfillment on a placed order | `fulfillment_create_workflow` → `Fulfillments::Create` | `validate`, `get_provider_data`, `after_create` |
| Edit (tracking, rate, location) | `fulfillment_update_workflow` → `Fulfillments::Update` | |
| Buy a label ahead of dispatch | `fulfillment_purchase_label_workflow` → `Fulfillments::PurchaseLabel` | |
| Carrier tracking update | `delivery_update_tracking_workflow` → `Deliveries::UpdateTracking` | `validate`, `after_update_tracking` |
```ruby
result = Spree.fulfillment_fulfill_workflow.call(
fulfillment: fulfillment,
items: [{ line_item: line_item, quantity: 1 }], # omit to ship everything
tracking: '1Z999AA10123456784', # becomes the primary Spree::Delivery
notify_customer: true,
force: false
)
result.value # the fulfillment that actually shipped — for a partial ship, the NEW split-off one
What Fulfill does, in order: validate hook → guards → split off requested units (via Fulfillments::Create, cost 0) → record tracking → buy a label if the provider generates_labels? and none is active (failure is reported, parcel still ships) → provider.create_fulfillment → unstock allocated units (shipped movements) → mark items shipped + fulfillment fulfilled + publish fulfillment.fulfilled → capture payments whose method charges on dispatch → roll up order statuses.
Manual fulfillments (mirroring a 3PL or courier back into Spree): POST /api/v3/admin/orders/:order_id/fulfillments with stock_location_id, items: [{ item_id: 'li_…', quantity }], optional tracking, delivery_method_id, cost, status, metadata. Units move out of their current fulfillments (restock/unstock when locations differ); emptied sources are destroyed and their cost carries over. Quirk: the only accepted status value is the legacy 'shipped' (registers it already fulfilled); anything else fails invalid_status.
Splitting without shipping: PATCH …/fulfillments/:id/split takes variant_id, quantity, optional stock_location_id (moves units to a new fulfillment, possibly at another location) and returns every fulfillment on the order.
Gating and custom stages
Gate handover with a validate hook — handlers receive the workflow and call reject!:
# config/initializers/spree.rb
Spree.hooks.register('fulfillments.fulfill.validate') do |flow|
work_order = MyApp::WorkOrder.find_by(fulfillment_id: flow.fulfillment.id)
flow.reject!('Still in production') unless work_order&.completed?
end
Hook keys are <workflow key>.<hook> where the workflow key is the class name minus Spree::, underscored and dotted (Spree::Fulfillments::Fulfill → fulfillments.fulfill). Unknown keys fail boot when eager loading.
Add a real status only when staff filter by it, reports group by it, or webhooks should fire on it:
# config/initializers/spree.rb — to_prepare so it survives dev reloads
Rails.application.config.to_prepare do
Spree::Fulfillment.add_status('in_production', after: 'unfulfilled')
end
That gives in_production?, .in_production and a valid value. Moving into it is your own Spree::Workflow (see spree-workflows); core fulfill/cancel keep working on it. Statuses are additive only — never remove a core one.
Rates: calculators vs rate providers
Every DeliveryMethod has a rate_provider (class name string, validated against Spree.delivery_rate_providers) and a calculator:
| Need | Use |
|---|---|
| Arithmetic on what's in the parcel (flat, per item, % of item total, price sack, flexi, free for digital) | A calculator — built-ins under Spree::Calculator::Shipping::* |
| A price that comes from outside Spree (carrier API, aggregator, contract rate card service) | A rate provider — Spree::DeliveryRateProvider::Base subclass |
| "Quoted after review" (pallets, containers) | Spree::DeliveryRateProvider::Freight — returns an unpriced rate carrying the freight summary |
| "Only offer this when …" (order value, weight, volume, company buyer, channel, excluded products) | A DeliveryMethodRule on the method, not calculator thresholds (the calculator minimum_*/maximum_* preferences are deprecated) |
Default provider is Spree::DeliveryRateProvider::Internal (prices via the calculator). Amount-based calculators carry a currency preference — a calculator whose currency doesn't match the order contributes nothing (no conversion). Create one method or calculator per currency you sell in. Rate-provider estimates in another currency are dropped by Spree::Stock::Estimator for the same reason.
Custom calculator:
class MyApp::Calculator::DimensionalWeight < Spree::ShippingCalculator
preference :rate_per_kg, :decimal, default: 0
preference :currency, :string, default: -> { Spree::Store.default&.default_currency }
def self.description = 'Dimensional weight'
def supports_currency?(currency) = currency.casecmp?(preferred_currency)
def compute_package(package)
package.weight * preferred_rate_per_kg # nil hides the method
end
end
# config/initializers/spree.rb — core ASSIGNS this list in after_initialize, so append there
Rails.application.config.after_initialize do
Spree.calculators.shipping_methods << MyApp::Calculator::DimensionalWeight
end
Custom carrier rate provider (full contract, service catalog and testing in references/provider-contracts.md):
module SpreeAcme
class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
def self.integration_class = 'SpreeAcme::Integration' # hidden/refused until connected
def self.requires_address? = true # it quotes real shipments
def estimates(package)
integration.client.rates(from: package.stock_location, to: package.order.ship_address, weight: package.weight).map do |q|
Spree::DeliveryRateProvider::Estimate.new(
cost: q.amount, currency: q.currency, carrier: q.carrier,
service_level: q.service, estimated_delivery_date: q.eta, metadata: { quote_id: q.id }
)
end
rescue SpreeAcme::Error => e
Rails.error.report(e, source: 'acme.rating')
[] # never raise into checkout — an empty array hides the method
end
end
end
Rails.application.config.after_initialize do
Spree.integrations << 'SpreeAcme::Integration'
Spree.delivery_rate_providers << SpreeAcme::DeliveryRateProvider
end
cost is pre-tax; Spree applies tax afterwards. The reference implementation is the spree_easypost gem (SpreeEasyPost::DeliveryRateProvider + SpreeEasyPost::FulfillmentProvider + SpreeEasyPost::Integration).
Fulfillment providers (dispatch, labels, 3PL/WMS)
delivery_method.fulfillment_provider (default Spree::FulfillmentProvider::Manual; also Digital, Pickup, PickupPoint) owns the outbound mechanics. A WMS/3PL/carrier integration subclasses Spree::FulfillmentProvider::Base and implements create_fulfillment(fulfillment) (return { tracking_number:, tracking_url: } optionally) and cancel_fulfillment(fulfillment); label-generating carriers also override self.generates_labels?, purchase_label(owner) (owner is a Fulfillment or a Return), refund_label(label), and optionally tracking_url(delivery), documents(owner), can_fulfill?(fulfillment). Register in after_initialize: Spree.fulfillment_providers << MyProvider (core assigns that list too).
Carrier tracking webhook: Spree owns POST /api/v3/webhooks/fulfillments/:integration_id (prefixed integration ID, no API key). The integration is looked up by ID alone and the request runs in the integration's own store (Spree::Current.store = integration.store), so one URL per integration works for every store. Your Spree::Integration subclass implements parse_webhook_event(raw_post, headers): verify the signature (raise Spree::Integration::WebhookSignatureError → 401, also when no secret is configured), return { tracking_code:, tracking_status:, estimated_delivery_at:, delivered_at:, details: } or nil to ignore (→ 200). The controller finds the Spree::Delivery in the integration's store by tracking number and runs Deliveries::UpdateTracking; a delivered status confirms receipt on the fulfillment. Map unknown carrier statuses to 'unknown'.
Order routing and stock splitters
Two layers decide how items become fulfillments:
- Which stock locations — order routing.
Spree::OrderRouting::Strategy::Rules(default) walks the channel'sSpree::OrderRoutingRulerows (PreferredLocation,MinimizeSplits,DefaultLocation; lower rank wins,nilabstains). Custom rule: subclassSpree::OrderRoutingRule, implementrank(order, locations)returning oneLocationRankingper location, register viaSpree.order_routing.rules << MyRule, create a row per channel. Custom strategy: subclassSpree::OrderRouting::Strategy::Base, implementfor_allocation(returnsSpree::Stock::Packages) plusfor_sale(fulfillment:),for_release,for_cancellation; register viaSpree.order_routing.strategies << MyStrategy, select withstore.preferred_order_routing_strategy/channel.preferred_order_routing_strategy(unregistered values fall back to Rules). - How one location's allocation is broken into packages — splitters. Default chain
Spree.stock_splitters = [Splitter::DeliveryProfile, Splitter::Backordered];Splitter::Weight(class-levelthreshold, default 150) is opt-in. Custom: subclassSpree::Stock::Splitter::Base, implementsplit(packages)and always end withreturn_next(packages).
Know where routing actually applies. Storefront checkout builds fulfillments with Cart#rebuild_fulfillments!, which calls Spree::Stock::Coordinator directly — it respects delivery-profile origin groups, the channel's served locations and the splitter chain, but does not consult the routing strategy or routing rules. Carts::Complete copies the cart's fulfillments onto the order. The routing strategy runs in Order#rebuild_fulfillments! — admin draft-order create/update (Spree::Orders::BuildFulfillments, a no-op once the order is placed) and OrderInventory changes on placed orders. Editing a placed order's items never rebuilds its fulfillments — Spree::OrderInventory adjusts them in place, so the allocations made at placement stay on rows that cancel/ship can still release. The strategy's for_sale/for_release/for_cancellation are part of the contract but core does not call them today (Rules implements them as no-ops) — do post-allocation side effects in fulfillment workflow hooks or fulfillment.* subscribers instead. Claim/exchange replacements also use the Coordinator. To change which warehouse storefront carts ship from, use delivery profiles/origin groups, channel stock locations, or a decorator on the Coordinator — not a routing rule alone.
# Splitters are assigned by core in after_initialize — append there, not in to_prepare
Rails.application.config.after_initialize do
Spree.stock_splitters << MyApp::Stock::Splitter::Refrigerated
end
Pickup, digital, freight
- Pickup: methods with
FulfillmentProvider::Pickupserve pickup-enabled stock locations (GET /api/v3/store/delivery_methods/:id/pickup_locations);PickupPointasks a pickup point provider (…/pickup_points?latitude=&longitude=). Pickup orders are closed out with mark-delivered. - Digital: assign the product the store's digital delivery profile. The
Digitalfulfillment provider auto-fulfills on placement and grantsSpree::DigitalLinks (eventdigital_link.downloaded). Deliver something other than an uploaded file via aSpree::DigitalAssetProvider::Basesubclass (deliver(digital_link, expires_in:)), registered inSpree.digital_asset_providers. - Freight: methods on the
Freightrate provider bounded byvolume_rule/company_rule; rates come backunpriced: true, display "Quoted after review", sort after priced rates, and checkout still completes.
API surface
- Store:
PATCH /api/v3/store/carts/:cart_id/fulfillments/:idwithselected_delivery_rate_id; rates are oncart.fulfillments[].delivery_rates. - The choice survives item changes. Every recalculation rebuilds the proposals (
rebuild_fulfillments!), and the rebuild carries each stock location's selected rate over (same carrier service, else same method, else the estimator's default) and prices it for the new contents. Only a destination change (Address::DESTINATION_FIELDS: address lines, city, state, postal code, country) resets to the default rate; a phone or name edit keeps it. A placed order's delivery isn't re-priced by a rebuild. - Admin — permission keys:
read_/write_fulfillmentsfor the fulfillments endpoints (nested labels/deliveries sit under*_orders),*_delivery_methodsfor methods/rules,*_package_types,*_settingsfor zones and profiles. Endpoints:…/orders/:order_id/fulfillments(index/show/create/update) + memberfulfill(items: [{ item_id, quantity }],tracking,tracking_carrier,notify_customer,force),mark_delivered,cancel,split; nestedlabels(create/destroy,download,refund) anddeliveries(CRUD,mark_delivered). Setup:delivery_methods(+calculators,rate_providers,fulfillment_providers, nestedrules),delivery_method_rules/types,delivery_zones,delivery_profiles(+kinds, nestedorigin_groups),package_types,tracking_carriers. - Admin SDK:
adminClient.orders.fulfillments.{list,fulfill,markDelivered,cancel,split},.labels.*,.deliveries.*.
Events
fulfillment.created/updated/deleted, fulfillment.fulfilled (metadata notify_customer), fulfillment.delivered, fulfillment.canceled; delivery.created/updated/deleted; shipping_label.purchased/.refunded (+ lifecycle); order-level order.fulfilled, order.delivered. There is no fulfillment.resumed. Use subscribers for WMS sync and emails (spree-events-webhooks).
Troubleshooting
No delivery rates at checkout (cart shows a delivery_unavailable warning, fulfillment pruned):
- The product's delivery profile has no method whose zone (or origin group) covers the address / source location.
- A
DeliveryMethodRuleexcludes the package (item total, weight, volume, company, channel). - Calculator currency ≠ cart currency, or the calculator returned
nil. - The rate provider's integration isn't connected, it raised (check error reports), or it returned only foreign-currency estimates.
- Console:
Spree::Stock::Coordinator.new(cart).packages.map { |p| [p.stock_location.name, p.delivery_rates.map(&:name)] }, orSpree::Stock::Estimator.new(cart).delivery_rates(package, Spree::DeliveryMethod::BACKOFFICE)to include admin-only methods.
Fulfill returns 422: read the error — unpaid (force or capture first), backordered units, not enough count_on_hand at the location (correct stock, or force), draft order, or your own validate hook.
Ships from the wrong warehouse: storefront carts ignore routing rules (see above) — check the product's delivery profile origin groups, the channel's stock locations, and StockLevel presence at each location.
Tracking webhook does nothing: the endpoint answers 200 for unknown tracking codes and nil events by design; 401 means signature verification failed; the integration must be active in the current store.
Testing
Factories (spree_dev_tools / spree/testing_support): :fulfillment, :fulfillment_item, :delivery_method, :free_delivery_method, :digital_delivery_method, :pickup_delivery_method, :delivery_zone, :delivery_zone_with_country, :delivery_profile, :digital_delivery_profile, :delivery_rate, :delivery, :stock_package, :cart_ready_for_delivery, :completed_order_with_totals. Stub carrier clients — rate quoting runs on every checkout. Set Spree::Current.store in specs that build rates outside a request.
Where to read further
node_modules/@spree/docs/dist/developer/core-concepts/fulfillments.md,delivery-setup.md,freight.md,calculators.mdnode_modules/@spree/docs/dist/developer/how-to/custom-delivery-rate-provider.md,custom-order-routing.md,custom-stock-splitter.md,sell-digital-products.md,custom-digital-asset-provider.mdnode_modules/@spree/docs/dist/developer/providers/fulfillment.md; EasyPost setup:node_modules/@spree/docs/dist/integrations/shipping/easypost.md- Deeper contracts:
references/provider-contracts.md - Related skills: spree-inventory (stock levels, reservations, movements), spree-returns, spree-workflows (hooks, custom workflows), spree-providers, spree-checkout, spree-order-totals.