Spree Checkout
Before writing code
Fetch live docs:
- Fetch https://spreecommerce.org/docs/developer/core-concepts/orders for the canonical state machine.
- Fetch https://spreecommerce.org/docs/developer/core-concepts/payments for the Payment + PaymentSession (v5.4+) flow.
- Check the live
Spree::Ordersource on GitHub for thestate_machineblock — transitions and callbacks change. - For headless checkout, fetch the v3 Store API docs at https://spreecommerce.org/docs/api-reference.
- Verify any custom-step pattern against the latest examples in
spree-starter.
Conceptual Architecture
The Order State Machine
cart → address → delivery → payment → confirm → complete
↓
skip if store-credit-covered
Each transition validates prerequisites:
| State | Prerequisites |
|---|---|
cart | One or more line items |
address | Bill + ship address present |
delivery | Shipping method selected for every shipment |
payment | At least one payment method valid for the total |
confirm | Optional review step (configurable) |
complete | All sub-states valid; transitions trigger fulfillment + emails |
Payment Sub-State
Order#payment_state is a separate field summarizing all Payment rows:
| Value | Meaning |
|---|---|
balance_due | Outstanding amount remains |
paid | Fully paid |
credit_owed | Refund pending |
failed | All payments failed |
void | Voided |
Shipment Sub-State
Order#shipment_state summarizes all Shipment rows:
| Value | Meaning |
|---|---|
pending | Awaiting payment / stock |
ready | Ready to ship |
partial | Some shipped |
shipped | All shipped |
backorder | Inventory shortfall |
canceled | Canceled |
Individual Shipment / Payment State Machines
Shipment#state:pending → ready → shipped(+canceled)Payment#state:checkout → processing → pending → completed(+failed,void,invalid)
Return Flow
ReturnAuthorization (authorized | canceled)
→ CustomerReturn
→ Reimbursement (pending | reimbursed | errored)
→ Refund (against original Payment)
StoreCredit reimbursements skip the Refund step and credit the user's balance.
Skipping the Payment Step
If order.outstanding_balance.zero? after store-credit/gift-card application, the state machine skips payment and goes straight to confirm. Useful for free-trial / 100%-off scenarios.
Guest vs Authenticated Checkout
Spree supports guest checkout by default — orders carry an email and order_token even without a User. The token allows a guest to revisit their order. Convert guests to users post-checkout via Spree::Order#associate_user!.
Custom Checkout Steps
Add a custom step by inserting into the state machine via decorator:
# app/models/spree/order_decorator.rb
module MyApp::OrderDecorator
def self.prepended(base)
base.state_machine.before_transition to: :delivery, do: :verify_gift_message
end
def verify_gift_message
# …
end
Spree::Order.prepend(self)
end
Custom steps are powerful but upgrade-fragile — Spree's state machine evolves. Prefer events or service objects when you only need to react.
Checkout via API v3 (v5.4+)
Headless checkout typically:
POST /api/v3/store/cart— create cart (returnsord_…ID + cart token)POST /api/v3/store/cart/line_items— add itemsPUT /api/v3/store/checkout— set addresses, shipping method, payment methodPOST /api/v3/store/checkout/payment_sessions— create a PaymentSession (Stripe/Adyen/PayPal)POST /api/v3/store/checkout/complete— finalize
(Verify exact paths in the v3 API reference — endpoint shapes are still settling.)
Payment Sessions (v5.4+)
The v5.4 PaymentSession abstracts the payment-provider handshake. The storefront creates a PaymentSession, the user authorizes via the gateway's hosted UI (Stripe Elements, Adyen Drop-in, PayPal Checkout), and the session is captured into a Payment on completion. Provider-specific.
Implementation Guidance
Reading the Current State
order.state # one of cart/address/delivery/payment/confirm/complete
order.payment_state # balance_due/paid/...
order.shipment_state # pending/ready/...
order.can_transition?(:complete) # check before triggering
Triggering Transitions Programmatically
order.next! # advance to the next state if valid
order.complete! # force to complete if valid (typically last step)
order.cancel! # cancel + revert inventory
Never call update_attribute(:state, …) directly — bypasses callbacks and corrupts inventory/payments.
Subscribing to Checkout Events
class CheckoutSubscriber < Spree::Subscriber
subscribes_to 'order.completed'
on 'order.completed', :send_welcome_email
def send_welcome_email(event)
return if event.order.user.nil?
# …
end
end
Events fire after the database commit — safe to enqueue background jobs.
Headless Checkout Patterns
- Always use httpOnly cookies for the cart/order token, not localStorage.
- Confirm the cart server-side before showing the review step — prices, tax, shipping can change between page loads.
- Idempotency-Key headers on
/complete— prevent double-charging on retry. - Use Payment Sessions for v5.4+ — they handle 3DS / SCA / Apple Pay / Google Pay uniformly.
Debugging Stuck Transitions
order.errors.full_messages
order.valid?(state) # validate for a specific state
order.checkout_steps # configured step list
Returns Workflow
ra = Spree::ReturnAuthorization.create!(order: order, return_items: items)
ra.authorize!
cr = Spree::CustomerReturn.create!(return_items: ra.return_items, stock_location: location)
cr.fully_received?
reimbursement = Spree::Reimbursement.create!(customer_return: cr, order: order)
reimbursement.perform! # creates Refunds or StoreCredits
Common Pitfalls
- Bypassing the state machine by setting
statedirectly → corrupts inventory and payment totals. - Forgetting to recompute totals after adjusting line items → use
Spree::OrderUpdater. - Marking a Payment
completedmanually → use the gateway's capture flow; manual completion skips reconciliation. - Treating
confirmas required — it's configurable (checkout_stepsorder). - Headless checkout drift — the storefront's local state can diverge from the server's
Order#state. Re-fetch after every mutating call. - Custom decorator on
state_machine— survives minor upgrades poorly; prefer event subscribers when possible.
Always re-verify state names and transition guards against the live Spree::Order source for the version you target.