spree-fulfillment
Use when working with how Spree 6 gets goods to the customer — fulfillments (`ful_`), delivery methods, delivery zones, delivery profiles and origin groups, delivery rates, calculators and carrier rate providers (EasyPost, freight), order routing and stock splitters, partial fulfillment and splitting, shipping labels, tracking/deliveries, carrier tracking webhooks, pickup and digital delivery. Common phrasings include "shipping method", "no delivery rates at checkout", "ship from the nearest warehouse", "custom shipping calculator", "live carrier rates", "mark as shipped", "partial shipment", "tracking number", "carrier webhook", "click and collect", "3PL / WMS integration", "fulfillment stuck unfulfilled", "add a fulfillment status", "fulfillment.fulfilled event", "Spree::Shipment". Returns/exchanges/claims live in spree-returns; stock counts, reservations and transfers in spree-inventory.
How do I install this agent skill?
npx skills add https://github.com/spree/agent-skills --skill spree-fulfillmentIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill is a technical development guide for the Spree 6 fulfillment system. It provides documentation and code examples for managing e-commerce shipping logic, carrier integrations, and stock splitting. No malicious patterns, obfuscation, or security risks were identified.
- Socketpass
No alerts
- Snykwarn
Risk: MEDIUM · 2 issues
What does this agent skill do?
Spree Fulfillment
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_idduring checkout,order_idafter placement. Readfulfillment.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 exposesfulfillment_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?isline_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 |
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.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/spree/agent-skills/spree-fulfillment">View spree-fulfillment on skillZs</a>