Overview
A delivery rate provider decides where a delivery method’s price comes from. Spree ships with one built-in provider,Spree::DeliveryRateProvider::Internal, which prices through the method’s calculator — flat rates, per-item rates, and the rest. That is the default, and stores that never install a carrier integration keep using it.
A custom provider replaces that arithmetic with a live quote: you call a carrier or an aggregator, and the rate that reaches checkout carries the carrier name, the service level, and an estimated delivery date.
Use a provider when the price comes from outside Spree. If you only need different arithmetic on values Spree already has, write a calculator instead — it is far less work. See Fulfillments for how delivery methods, rates and fulfillments fit together.
What you will build
Three pieces, each with one job:
Providers are stateless strategy objects. They never store credentials themselves — that is the integration’s job, which is what gives merchants one place to see everything connected to their store.
1. Store credentials in an integration
:password preferences. Spree masks them on read and guards the round-trip on write, so an API key never leaves the server in plain text.
2. Implement the provider
Implement#estimates — it receives a Spree::Stock::Package and returns an array of Estimate objects, one per carrier service you can quote. Every estimate becomes its own named option at checkout (“Acme Ground”, “Acme Express”), and the merchant narrows, renames, or marks up individual services from the delivery method’s carrier-services card. An empty array hides the method.
(Single-quote providers can implement #estimate returning one Estimate or nil instead — the base class wraps it.)
estimates raise. It runs inside checkout’s rate refresh, so an exception breaks the whole delivery step for the customer. Rescue your carrier’s errors, report them (Rails.error.report), and return [] — the method disappears from the options while everything else keeps working.
Optionally implement def self.service_catalog(integration) so the admin service picker can list the carrier’s services as checkboxes. Fetch them live — a hardcoded list offers services the merchant has not enabled and hides the ones they have. Return one of three shapes, and never raise:
ServiceCatalog.none is the default: the provider lists nothing and the merchant types identifiers free-form, which also stays available when a listing fails. Carrier and service values must match what your rates carry — the picker’s rows are matched against quoted rates by exactly those two fields.
Returning nil hides the delivery method for that package. This is the same contract calculators follow, and it is how you express “this carrier does not serve this destination” — the method simply does not appear at checkout rather than appearing at a wrong price.
cost is pre-tax and pre-VAT. Spree applies the gross-up and resolves the tax rate afterwards, exactly as it does for calculator output, so you never handle tax yourself.
Declaring integration_class is all the availability wiring you need: Spree derives available_for_store? from it, so your provider is hidden from the admin picker — and rejected on save — until the merchant connects the integration.
Optional lifecycle hooks
book and release are part of the provider contract but Spree does not invoke them yet — they are reserved for the rate booking flow. Until then, call book yourself from your gem’s fulfillment provider when the label is purchased:
3. Register both classes
rate_provider against this list, so a typo fails when the method is saved rather than deep inside checkout.
4. Use it
A merchant connects the integration under Settings → Integrations, then picks the provider on a delivery method under Settings → Delivery methods. The provider field only appears once more than one provider is available, so stores without a carrier integration never see it. One delivery method is the carrier connection: every service your provider returns becomes its own option at checkout, so a single “Acme shipping” method is usually all a merchant creates. The method’s carrier-services card narrows which services are offered, renames them (“Acme 1 day”), and adds per-service or method-wide markup. Providers and calculators coexist freely. A store can price “Free shipping” with a flat-rate calculator, “Express” through your carrier, and “Local pickup” with neither.Feeding carrier tracking back in
Once a parcel is moving, most carriers will tell you where it is. Spree keeps that on a separate field from the fulfillment’s own status, so a bounced parcel never un-ships itself — see Fulfillments. Spree owns the endpoint: every integration gets one atPOST /api/v3/webhooks/fulfillments/:integration_id — the merchant pastes
that URL into the carrier’s webhook settings. Your job is one method on the
integration:
401 so a misconfigured
sender notices, unmatched tracking codes and nil events are acknowledged with
200 so the carrier never retries a payload that cannot succeed, and matched
events run through Fulfillments::UpdateTracking — reporting delivered also
confirms receipt on the fulfillment, and an update carrying only a scan leaves
an earlier estimate alone.
Three rules worth keeping:
- Verify or refuse. Raise
WebhookSignatureErrorwhen no secret is configured rather than accepting unsigned reports — a forgeddeliveredstarts the customer’s return window and, in the EU, the withdrawal clock. - Pass
delivered_atfrom the carrier’s delivering scan, not the webhook’s arrival time. The webhook usually lands well after the parcel does. - Translate in your gem, never in core. Map unrecognised carrier statuses
to
unknownso the report stays visible instead of being dropped.

