What a provider decides
Core calls a provider at exactly three moments. Each verb takes the ledger row it is about and returns it:
Alongside the verbs, a provider tells core what it needs from sellers before it can pay them:
And three class-level descriptors:
display_name (what the operator picks in settings), reference_system (the key a seller’s account is filed under — a lowercase identifier, matching your Spree::Integration key if you ship one), and available_for_store?(store) (false until the store holds credentials for you). Leave provider_key alone: it is the full class name, and it is what the ledger’s unique index on (provider, reference) keys on.
Providers are stateless and built with no arguments. Anything request-specific arrives as a parameter.
Writing your own
The example below settles sellers by bank transfer through a fictional payments API. It moves money only at payout time, sotransfer! is pure bookkeeping.
server/app/models/my_app/payout_provider.rb
server/app/models/my_app/acme_integration.rb
Register it
server/config/initializers/spree.rb
payout_provider preference. The picker is built from the registry, so your provider appears with the two built-in ones and reports whether the store can use it today:
def self.provider_key pinned to the old name if you ever need to, or the rows written under the old name stop matching.
Errors decide what happens next
Core reads two exceptions, and which one you raise decides whether the money is offered to your API again:
Get this right. Raising a plain error on a timeout is how the same settlement is sent twice: the payout is failed, its earnings go back on the pile, and the next sweep batches them into a new payout with a new idempotency key — one your provider has never seen.
Idempotency
Every call carriesidempotency_key(record) — spree-<prefixed id> of the ledger row. Pass it to your API on every write. A crash between your API answering and Spree committing is retried, and the retry must find the movement it already made rather than make a second.
If your rails have no idempotency keys, look the movement up by that key (or by the row’s prefixed id in your own metadata) before creating it.
Seller accounts
A seller’s account with your provider is stored as an external reference filed under yourreference_system, so a marketplace that migrates between providers keeps every account on record:
The reverse lookup is a uniquely indexed read, scoped to the store’s own sellers — a webhook naming an account from another store finds nothing.
Onboarding
When a store’s provider requires an account, the operator adds the Payout account row to the seller onboarding checklist. That row reads youronboarded? and onboarding_state, and the seller panel’s button asks for a link at the moment it is clicked:
onboarding_url. A Spree::Core::GatewayError raised there becomes a 422 the panel can show; anything else is a crash.
Confirming from a webhook
Two things only your provider can tell Spree, and both usually arrive as webhooks: that a seller became payable, and that a payout landed or bounced. The Stripe provider receives them through the shipped/api/v3/webhooks/payouts/:payment_method_id route, which works for any provider that is also a payment method and implements handle_payout_webhook(raw_body, headers) on its gateway. A standalone provider adds its own endpoint to the host app.
Address it to the integration rather than the store: the integration is what holds the signing secret, and the store follows from it.
server/config/routes.rb
server/app/controllers/acme_webhooks_controller.rb
fail! releases the payout’s earnings so the next sweep picks them up again. Completing publishes seller_payout.completed, which is what the seller’s balance and their panel read.
What to test
The Stripe provider’s spec in the monorepo (spree/providers/stripe/spec/models/spree_stripe/payout_provider_spec.rb) is the reference battery. The cases worth copying:
pay!passes the idempotency key and stores the provider’s id asreference, leaving the statuspending- A timeout raises
AmbiguousGatewayError, a refusal raisesGatewayError— the two the ledger tells apart - Amounts are sent in the units your API expects (
Spree::Money::Rounding.to_minor_units(amount, currency)for cents) onboarded?asks the API rather than reading the cached stamp
Refund clawbacks netted across settlements, reconciliation against provider statements, KYC operations and seller tax reporting (DAC7) are Spree Enterprise. Open source ships the ledger, the contract, and the two providers above.
Related
- Sellers — payouts — the ledger, the schedule, and the admin API around it
- Commissions — what a seller’s earning is net of
- Stripe Connect for marketplaces — the shipped provider that moves money

