> ## Documentation Index
> Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Custom Delivery Rate Provider

> Connect a carrier or rate aggregator to Spree so checkout quotes live delivery rates instead of calculator-based prices.

## 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](/docs/developer/core-concepts/fulfillments) for how delivery methods, rates and fulfillments fit together.

## What you will build

Three pieces, each with one job:

| Piece                                        | Responsibility                                                  |
| -------------------------------------------- | --------------------------------------------------------------- |
| `Spree::Integration` subclass                | Holds the store's credentials, managed from the admin dashboard |
| `Spree::DeliveryRateProvider::Base` subclass | Turns a package into a rate quote                               |
| An initializer                               | Registers both so admins can select them                        |

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

```ruby theme={"theme":"night-owl"}
module SpreeAcmeCarrier
  class Integration < Spree::Integration
    preference :api_key, :password
    preference :account_number, :string
    preference :test_mode, :boolean, default: true

    def self.integration_group = 'shipping'

    # Called when an admin activates the integration. Returning false blocks
    # activation and shows your message in the dashboard.
    def can_connect?
      client.ping
      true
    rescue StandardError => e
      self.connection_error_message = e.message
      false
    end

    def client
      @client ||= AcmeCarrier::Client.new(api_key: preferred_api_key, test: preferred_test_mode)
    end
  end
end
```

Declare secrets as `: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.)

```ruby theme={"theme":"night-owl"}
module SpreeAcmeCarrier
  class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
    def self.integration_class = 'SpreeAcmeCarrier::Integration'

    def estimates(package)
      quotes_for(package).map do |quote|
        Spree::DeliveryRateProvider::Estimate.new(
          cost: quote.amount,
          # ISO code of the currency the carrier quoted in. Estimates in a
          # different currency than the cart's are dropped — never convert
          # silently; nil means the store's own currency.
          currency: quote.currency,
          carrier: quote.carrier_name,
          service_level: quote.service_name,
          estimated_delivery_date: quote.delivery_date,
          metadata: { quote_id: quote.id }
        )
      end
    rescue AcmeCarrier::Error => e
      # Never raise into checkout: report and hide this method instead.
      Rails.error.report(e, context: { delivery_method_id: delivery_method.id }, source: 'acme.rating')
      []
    end

    private

    # One API call serves every delivery method sharing this provider within
    # a request — carriers return all services in one response, so quoting
    # five methods should not mean five round-trips.
    def quotes_for(package)
      key = [:acme_quotes, store.id, package.stock_location.id, package.order.id]
      Spree::Current.provider_cache[key] ||= integration.client.rates(
        from: package.stock_location,
        to: package.order.ship_address,
        parcel: { weight: package.weight }
      )
    end
  end
end
```

**Never let `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:

```ruby theme={"theme":"night-owl"}
def self.service_catalog(integration)
  return Spree::DeliveryRateProvider::ServiceCatalog.none if integration.nil?

  services = integration.client.services.map do |service|
    { carrier: service.carrier, service: service.code, label: service.name }
  end
  Spree::DeliveryRateProvider::ServiceCatalog.listing(services)
rescue AcmeCarrier::Error => e
  # The merchant sees the carrier's own words — "no services" and "we
  # could not ask" are different problems.
  Spree::DeliveryRateProvider::ServiceCatalog.unavailable(e.message)
end
```

`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:

```ruby theme={"theme":"night-owl"}
# Reserve the quote once your fulfillment provider dispatches.
def book(delivery_rate)
  integration.client.book(delivery_rate.metadata['quote_id'])
end

# Release a quote that is no longer wanted.
def release(delivery_rate)
  integration.client.release(delivery_rate.metadata['quote_id'])
end
```

## 3. Register both classes

```ruby theme={"theme":"night-owl"}
# config/initializers/spree.rb
Spree.delivery_rate_providers << 'SpreeAcmeCarrier::DeliveryRateProvider'.constantize
Spree.integrations << 'SpreeAcmeCarrier::Integration'
```

Registration is what makes a provider selectable: Spree validates `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](/docs/developer/core-concepts/fulfillments#statuses).

Spree owns the endpoint: every integration gets one at
`POST /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:

```ruby theme={"theme":"night-owl"}
class SpreeAcmeCarrier::Integration < Spree::Integration
  preference :api_key, :password
  preference :webhook_secret, :password

  # Verify the signature against your secret, translate the payload, return
  # UpdateTracking arguments plus the tracking code to match on — or nil for
  # events you don't act on.
  def parse_webhook_event(raw_post, headers)
    raise Spree::Integration::WebhookSignatureError if preferred_webhook_secret.blank?

    payload = AcmeCarrier.verify!(raw_post, headers, preferred_webhook_secret) # raises on a bad signature

    return unless payload['type'] == 'shipment.status_changed'

    {
      tracking_code: payload['tracking_code'],
      tracking_status: STATUS_MAP.fetch(payload['status'], 'unknown'),  # Spree::Fulfillment::TRACKING_STATUSES
      estimated_delivery_at: payload['eta'],
      delivered_at: payload['delivered_scan_at'],
      details: payload.slice('status_detail', 'carrier')
    }
  rescue AcmeCarrier::SignatureError => e
    raise Spree::Integration::WebhookSignatureError, e.message
  end
end
```

The endpoint does the rest: signature failures answer `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 `WebhookSignatureError` when no secret is
  configured rather than accepting unsigned reports — a forged `delivered`
  starts the customer's return window and, in the EU, the withdrawal clock.
* **Pass `delivered_at` from 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 `unknown` so the report stays visible instead of being dropped.

## Testing

Your provider is a plain object, so test it directly:

```ruby theme={"theme":"night-owl"}
RSpec.describe SpreeAcmeCarrier::DeliveryRateProvider do
  let(:delivery_method) { create(:delivery_method, rate_provider: described_class.to_s) }

  it 'quotes every service the carrier returns' do
    estimates = described_class.new(delivery_method).estimates(package)

    expect(estimates.map(&:service_level)).to contain_exactly('Ground', 'Express')
    expect(estimates.first.carrier).to eq('Acme')
  end

  it 'hides the method when the carrier does not serve the destination' do
    expect(described_class.new(delivery_method).estimates(unservable_package)).to eq([])
  end
end
```

Stub the carrier client rather than calling the real API — quoting runs on every checkout, so a slow or flaky test here is a slow or flaky suite everywhere.
