docs/developer/core-concepts/payments.mdx
Spree has a highly flexible payments model which allows multiple payment methods to be available during the checkout. The logic for processing payments is decoupled from orders, making it easy to define custom payment methods with their own processing logic.
Payment methods typically represent a payment gateway. Gateways will process card payments, online bank transfers, buy-now-pay-later, wallet payments, and other types of payments. Spree also comes with a Check option for offline processing.
The Payment model in Spree tracks payments against Orders. Payments relate to a source which indicates how the payment was made, and a PaymentMethod, indicating the processor used for this payment.
When a payment is created, it is given a unique, 8-character identifier. This is used when sending the payment details to the payment processor. Without this identifier, some payment gateways mistakenly reported duplicate payments.
erDiagram
Payment {
string number
decimal amount
string state
string response_code
string avs_response
string source_type
string source_id
}
PaymentMethod {
string name
string type
string description
boolean active
string display_on
integer position
}
PaymentSession {
string status
decimal amount
string currency
string external_id
json external_data
datetime expires_at
}
PaymentSetupSession {
string status
string external_id
string external_client_secret
json external_data
}
PaymentSource {
string type
string gateway_payment_profile_id
}
CreditCard {
string last_digits
string cc_type
integer month
integer year
string name
}
GatewayCustomer {
string profile_id
}
StoreCredit {
decimal amount
decimal amount_used
decimal amount_authorized
string currency
}
Refund {
decimal amount
string reason
}
LogEntry {
text details
}
Order ||--o{ Payment : "has many"
Order ||--o{ PaymentSession : "has many"
Payment }o--|| PaymentMethod : "belongs to"
Payment }o--o| CreditCard : "source"
Payment }o--o| PaymentSource : "source"
Payment }o--o| StoreCredit : "source"
Payment ||--o| PaymentSession : "linked via response_code"
Payment ||--o{ LogEntry : "has many"
Payment ||--o{ Refund : "has many"
PaymentMethod ||--o{ PaymentSession : "has many"
PaymentMethod ||--o{ PaymentSetupSession : "has many"
PaymentMethod ||--o{ GatewayCustomer : "has many"
PaymentMethod }o--|| Store : "belongs to"
PaymentSetupSession }o--o| PaymentSource : "creates"
PaymentSetupSession }o--|| Customer : "belongs to"
GatewayCustomer }o--|| Customer : "belongs to"
CreditCard }o--|| Customer : "belongs to"
StoreCredit }o--|| Customer : "belongs to"
Refund }o--|| Payment : "belongs to"
Refund }o--|| Reimbursement : "belongs to"
Key relationships:
Payment methods represent the different options a customer has for making a payment. Most sites will accept credit card payments through a payment gateway, but there are other options. Spree also comes with built-in support for a Check payment, which can be used to represent any offline payment. Gateway providers such as Stripe, Adyen, and PayPal provide a wide range of payment methods, including credit cards, bank transfers, buy-now-pay-later, and digital wallets (Apple Pay, Google Pay, etc.).
A PaymentMethod can have the following attributes:
| Attribute | Description | Example |
|---|---|---|
type | The payment method type | Check |
name | The visible name for this payment method | Check |
description | The description for this payment method | Pay by check. |
active | Whether or not this payment method is active. Set it false to hide it in the Store API. | true |
display_on | Determines where the payment method can be visible. Values can be front for Store API, back for admin panel only or both for both. | both |
position | The position of the payment method in lists. Lower numbers appear first. | 1 |
Payment methods indicate whether they use the modern session-based flow via the session_required? method:
| Method | Description | Default |
|---|---|---|
session_required? | Returns true if this payment method requires a Payment Session for processing. | false |
setup_session_supported? | Returns true if this payment method supports saving payment methods for future use (Payment Setup Sessions). | false |
payment_session_class | Returns the STI subclass of Spree::PaymentSession for this gateway (e.g., Spree::PaymentSessions::Stripe). | nil |
payment_setup_session_class | Returns the STI subclass of Spree::PaymentSetupSession for this gateway. | nil |
Modern gateways like Stripe and Adyen set session_required? to true. The Store API serializer includes this as the session_required field so your frontend knows which flow to use.
Payment methods where session_required? returns false don't need a payment session. These are typically offline or manual payment methods such as:
Spree::PaymentMethod::Check)For these methods, the Store API allows creating a payment directly without going through the payment session flow:
const options = { spreeToken: cart.token }
// Create a payment for a non-session payment method
const payment = await client.carts.payments.create(cart.id, {
payment_method_id: 'pm_xyz789',
amount: '99.99', // Optional, defaults to order total minus store credits
metadata: { // Optional, write-only metadata (e.g. PO number)
purchase_order_number: 'PO-12345',
},
}, options)
The payment is created in checkout state. When the order transitions to complete, Spree calls process_payments! which runs the payment method's authorize (or purchase if auto-capture is enabled). For manual payment methods like Check, this is a no-op that succeeds immediately — the payment moves to pending (or completed with auto-capture), allowing the order to complete.
The merchant can later capture or void the payment from the Admin Panel once the actual payment is received (e.g., check arrives, bank transfer clears, cash is collected on delivery).
Spree supports two payment flows depending on the payment method type:
Modern payment gateways use a three-phase approach: first a Payment Session is created with the gateway, then the customer completes payment on the frontend, and finally the order is completed via an explicit API call. Payment processing and order completion are intentionally separated — this prevents race conditions and ensures reliable checkout regardless of payment method type (cards, wallets, offsite redirects).
sequenceDiagram
participant Frontend
participant Spree API
participant Payment Provider
rect rgb(240, 245, 255)
note right of Frontend: Phase 1: Payment Session
Frontend->>Spree API: Create Payment Session
Spree API->>Payment Provider: Create session (PaymentIntent/Session)
Payment Provider-->>Spree API: Session ID + client_secret
Spree API-->>Frontend: PaymentSession (pending)
end
rect rgb(240, 255, 240)
note right of Frontend: Phase 2: Customer Pays
Frontend->>Payment Provider: Collect payment (using client_secret)
Note over Frontend,Payment Provider: 3DS / offsite redirect handled here
Payment Provider-->>Frontend: Payment result
end
rect rgb(255, 248, 240)
note right of Frontend: Phase 3: Complete Payment Session
Frontend->>Spree API: Complete Payment Session
Spree API->>Payment Provider: Verify payment status
Spree API->>Spree API: Create Payment record
Spree API-->>Frontend: PaymentSession (completed)
end
rect rgb(255, 240, 245)
note right of Frontend: Phase 4: Complete Order
Frontend->>Spree API: POST /carts/:id/complete
Spree API->>Spree API: Validate & finalize order
Spree API-->>Frontend: Completed order
end
<Info>
The payment session should be created (or recreated) **after** the shipping method is selected, so the amount includes shipping costs. If the order total changes (e.g., customer selects a different shipping rate or applies a coupon), create a new payment session with the updated amount.
</Info>
**This step does NOT complete the order** — it only handles payment processing. For wallet payments (Apple Pay, Google Pay), the gateway also patches the order's billing address with data from the wallet at this stage.
This separation ensures the same flow works for all payment types — inline cards, offsite redirects, and wallet payments.
For payment methods that redirect the customer away from your site, use an intermediate confirm-payment page:
sequenceDiagram
participant Frontend
participant Gateway
participant Spree API
Frontend->>Gateway: confirmPayment (redirects to gateway)
Gateway-->>Frontend: Redirect back to /confirm-payment/:id?session=...
Frontend->>Spree API: Complete Payment Session
Spree API-->>Frontend: Session completed
Frontend->>Spree API: POST /carts/:id/complete
Spree API-->>Frontend: Order completed
Frontend->>Frontend: Redirect to thank-you page
If the customer closes the browser after paying but before the frontend calls complete, Spree handles this via payment webhooks:
sequenceDiagram
participant Payment Provider
participant Spree API
Payment Provider->>Spree API: POST /api/v3/webhooks/payments/:pm_id
Spree API->>Spree API: Verify signature
Spree API->>Spree API: Enqueue HandleWebhookJob
Spree API-->>Payment Provider: 200 OK
note over Spree API: Async processing
Spree API->>Spree API: Create/update Payment
Spree API->>Spree API: Complete order via Carts::Complete
Gateway extensions implement parse_webhook_event to normalize provider-specific payloads into a standard format. Spree core handles the rest — creating the payment record, completing the session, and finalizing the order.
Non-session payment methods use a simpler flow where a payment is created directly without involving an external payment provider:
sequenceDiagram
participant Frontend
participant Spree API
Frontend->>Spree API: GET /payment_methods
Spree API-->>Frontend: Payment methods (with session_required flag)
Frontend->>Spree API: POST /payments (payment_method_id)
Spree API->>Spree API: Create Payment (checkout state)
Spree API-->>Frontend: Payment created
Frontend->>Spree API: PATCH /orders/:id/complete
Spree API->>Spree API: process_payments! (authorize succeeds immediately)
Spree API->>Spree API: Payment → pending/completed
Spree API-->>Frontend: Order completed
A PaymentSession (Spree::PaymentSession) represents a server-side session with the payment gateway. It is the entry point for every payment attempt and holds the provider-specific data needed by the frontend SDK.
| Attribute | Description | Example Value |
|---|---|---|
status | Current session state: pending, processing, completed, failed, canceled, expired | completed |
amount | The payment amount | 99.99 |
currency | ISO currency code | USD |
external_id | The provider-side session ID (e.g., Stripe PaymentIntent ID) | pi_3ABC123 |
external_data | Provider-specific data including client_secret for frontend SDK | {"client_secret": "pi_3ABC_secret_xyz"} |
customer_external_id | The provider's customer ID | cus_ABC123 |
expires_at | When the session expires | 2025-01-01T12:00:00Z |
stateDiagram-v2
[*] --> pending
pending --> processing
pending --> completed
pending --> failed
pending --> canceled
pending --> expired
processing --> completed
processing --> failed
processing --> canceled
processing --> expired
Create a Payment Session:
const options = { spreeToken: cart.token }
// Create a payment session for the selected payment method
const session = await client.carts.paymentSessions.create(cart.id, {
payment_method_id: 'pm_xyz789',
amount: '99.99', // Optional, defaults to order total
external_data: {}, // Optional, provider-specific data
}, options)
// The session contains provider-specific data for the frontend SDK
console.log(session.external_id) // e.g. 'pi_3ABC123' (Stripe PaymentIntent ID)
console.log(session.external_data.client_secret) // Use with Stripe.js or Adyen Drop-in
Response shape (StorePaymentSession):
{
"id": "ps_abc123",
"status": "pending",
"amount": "99.99",
"currency": "USD",
"external_id": "pi_3ABC123",
"external_data": {
"client_secret": "pi_3ABC123_secret_xyz"
},
"customer_external_id": "cus_ABC123",
"expires_at": "2025-01-01T12:00:00Z",
"payment_method_id": "pm_xyz789",
"order_id": "or_ABC123",
"payment": null
}
Update a Payment Session (e.g., after order total changes):
const updated = await client.carts.paymentSessions.update(
cart.id, session.id,
{ amount: '149.99' },
options
)
Complete a Payment Session (after customer confirms payment on the frontend):
const completed = await client.carts.paymentSessions.complete(
cart.id, session.id,
{ session_result: '...', external_data: {} },
options
)
console.log(completed.status) // 'completed'
Complete the Order (after the payment session is completed):
const order = await client.carts.complete(cart.id, options)
Spree provides a generic webhook endpoint at POST /api/v3/webhooks/payments/:payment_method_id that payment gateway extensions can use. When a payment provider sends a webhook (e.g., Stripe payment_intent.succeeded), Spree:
401 if invalid)200 OK immediatelyThe background job creates/updates the Payment record, marks the session as completed, and completes the order if needed.
Gateway extensions implement parse_webhook_event to normalize provider-specific payloads:
class MyGateway < Spree::Gateway
def parse_webhook_event(raw_body, headers)
# Verify signature — raise WebhookSignatureError if invalid
event = verify_signature(raw_body, headers)
case event.type
when 'payment.captured'
session = Spree::PaymentSession.find_by(external_id: event.payment_id)
{ action: :captured, payment_session: session }
when 'payment.failed'
session = Spree::PaymentSession.find_by(external_id: event.payment_id)
{ action: :failed, payment_session: session }
else
nil # unsupported event
end
end
end
Supported actions: :captured, :authorized, :failed, :canceled.
Once a Payment Session is completed, Spree creates a Payment record (Spree::Payment) to track the result. The Payment is linked to the session via response_code matching the session's external_id.
| Attribute | Description | Example Value |
|---|---|---|
number | A unique identifier for the payment. | P123456789 |
source_type | The type of source used for the payment. | Spree::CreditCard |
source_id | The ID of the source used for the payment. | 1 |
amount | The amount of the payment. | 99.99 |
payment_method_id | The ID of the payment method used. | 2 |
state | The current state of the payment (e.g., processing, completed, failed). | completed |
response_code | The gateway transaction ID. Links to the Payment Session's external_id. | pi_3ABC123 |
avs_response | The address verification system response code. | D |
After a Payment Session completes, the resulting Payment transitions through these states:
stateDiagram-v2
[*] --> checkout
checkout --> processing : process!
processing --> completed : purchase! succeeds
processing --> pending : authorize! succeeds
processing --> failed : payment declined
pending --> completed : capture!
pending --> void : void!
completed --> void : void!
checkout --> void : void!
| State | Description |
|---|---|
checkout | Initial state. The payment has been created but not yet processed. |
processing | The payment is being processed (temporary - prevents double submission). |
pending | The payment has been authorized but not yet captured. Awaiting manual or scheduled capture. |
failed | The payment was rejected (e.g., card declined, insufficient funds). |
void | The payment has been voided and should not be counted against the order. |
completed | The payment has been captured. Only payments in this state count against the order total. |
With auto-capture enabled (default for most gateways), the Payment goes directly from checkout → processing → completed. With manual capture, it stops at pending until an admin captures it.
Each payment update also recalculates the order's payment_state:
| Payment State | Description |
|---|---|
balance_due | Payment is required for this order |
failed | The last payment for the order failed |
credit_owed | This order has been paid for in excess of its total |
paid | This order has been paid for in full |
Responses from payment gateways are stored as log entries for debugging purposes. These can be viewed in the Admin Panel on the payment detail page.
Payment sources represent the actual instrument used for a payment. They are created automatically when a Payment Session completes.
Spree::CreditCard)Stores non-sensitive credit card information. With modern gateways, the actual card data is tokenized by the provider - Spree only stores reference IDs and display information.
| Attribute | Description | Example Value |
|---|---|---|
month | The month the credit card expires. | 6 |
year | The year the credit card expires. | 2026 |
cc_type | The type of credit card (e.g., visa, mastercard). | visa |
last_digits | The last four digits of the credit card number. | 1234 |
name | The name of the credit card holder. | John Doe |
gateway_payment_profile_id | The payment token from the gateway (e.g., Stripe pm_xxx, Adyen storedPaymentMethodId). | pm_1ABC123 |
A generic payment source model for non-card payment methods such as digital wallets, bank transfers, and buy-now-pay-later services. Gateway integrations create subtypes for each payment method type (e.g., Klarna, Afterpay, iDEAL, Apple Pay, Google Pay, PayPal).
Spree::GatewayCustomer)Maps a Spree customer to their provider-specific customer profile. This enables features like saved payment methods, recurring billing, and customer-level fraud detection.
| Attribute | Description | Example Value |
|---|---|---|
profile_id | The provider's customer ID (encrypted at rest) | cus_ABC123 |
payment_method_id | The gateway this customer belongs to | 1 |
user_id | The Spree user | 42 |
Each customer has at most one GatewayCustomer record per payment method. The profile_id is encrypted using Active Record Encryption when available.
Payment Setup Sessions (Spree::PaymentSetupSession) allow customers to save payment methods for future use without making an immediate payment. This maps to concepts like Stripe's SetupIntent - a secure way to collect and tokenize payment details for later charges.
sequenceDiagram
participant Frontend
participant Spree API
participant Payment Provider
Frontend->>Spree API: Create Payment Setup Session
Spree API->>Payment Provider: Create setup session (SetupIntent)
Payment Provider-->>Spree API: Session ID + client_secret
Spree API-->>Frontend: PaymentSetupSession (pending)
Frontend->>Payment Provider: Collect card details (using client_secret)
Note over Frontend,Payment Provider: 3DS verification if needed
Frontend->>Spree API: Complete Payment Setup Session
Spree API->>Payment Provider: Verify setup result
Spree API->>Spree API: Create PaymentSource (saved card)
Spree API-->>Frontend: Completed PaymentSetupSession
| Attribute | Description | Example Value |
|---|---|---|
status | Current session state: pending, processing, completed, failed, canceled, expired | completed |
external_id | The provider-side session ID (e.g., Stripe SetupIntent ID) | seti_ABC123 |
external_client_secret | Client secret for the frontend SDK | seti_ABC123_secret_xyz |
external_data | Provider-specific data | {} |
payment_source_id | The saved payment source created after completion | ps_xyz789 |
payment_source_type | The type of saved payment source | Spree::CreditCard |
Create a Payment Setup Session:
const options = { token: jwtToken }
// Create a setup session for saving a payment method
const setupSession = await client.customer.paymentSetupSessions.create({
payment_method_id: 'pm_xyz789',
external_data: {},
}, options)
// Use the client secret with the gateway's frontend SDK
console.log(setupSession.external_client_secret) // e.g. 'seti_ABC123_secret_xyz'
Response shape (StorePaymentSetupSession):
{
"id": "pss_abc123",
"status": "pending",
"external_id": "seti_ABC123",
"external_client_secret": "seti_ABC123_secret_xyz",
"external_data": {},
"payment_method_id": "pm_xyz789",
"payment_source_id": null,
"payment_source_type": null,
"customer_id": "usr_def456"
}
Get a Payment Setup Session:
const session = await client.customer.paymentSetupSessions.get('pss_abc123', options)
Complete a Payment Setup Session (after the customer completes setup on the frontend using the gateway SDK and external_client_secret):
const completed = await client.customer.paymentSetupSessions.complete(
'pss_abc123',
{ external_data: {} },
options
)
console.log(completed.status) // 'completed'
console.log(completed.payment_source_id) // e.g. 'ps_xyz789' - the saved payment method
Spree will verify the result with the provider and create a saved payment source (e.g., Spree::CreditCard) that can be used for future payments.
Spree team maintains several payment gateway integrations. All of these gateways are fully PCI compliant, using native gateway SDKs, meaning no sensitive payment data is stored or processed through Spree.
<Card title="Stripe" href="/integrations/payments/stripe" icon="link"> Stripe integration, supports all Stripe payment methods, including credit cards, bank transfers, Apple Pay, Google Pay, Klarna, Afterpay, and more. Also supports quick checkout. </Card> <Card title="Adyen" href="/integrations/payments/adyen" icon="link"> Adyen integration, supports all Adyen payment methods, including credit cards, bank transfers, Apple Pay, Google Pay, Klarna, and more. </Card> <Card title="PayPal" href="/integrations/payments/paypal" icon="link"> Native PayPal integration, supports PayPal, PayPal Credit, and PayPal Pay Later. </Card>Spree publishes events throughout the payment lifecycle that you can subscribe to:
| Event | Description |
|---|---|
payment.paid | Payment was completed |
order.paid | Order is fully paid |
| Event | Description |
|---|---|
payment_session.processing | Session is being processed |
payment_session.completed | Session completed successfully |
payment_session.failed | Session processing failed |
payment_session.canceled | Session was canceled |
payment_session.expired | Session expired |
| Event | Description |
|---|---|
payment_setup_session.completed | Setup completed, payment source saved |
payment_setup_session.failed | Setup failed |
payment_setup_session.canceled | Setup was canceled |
See Events for more details on subscribing to events.
| Service | Description |
|---|---|
Spree::Carts::Complete | Completes the order — validates, processes payments (if not already done), advances state machine. Used by both the POST /carts/:id/complete endpoint and the webhook handler. |
Spree::Payments::HandleWebhook | Processes a normalized webhook event — creates Payment, marks session completed, calls Carts::Complete. |
Spree::Payments::HandleWebhookJob | Background job that wraps HandleWebhook — enqueued by the webhook controller for async processing. |
Both Carts::Complete and HandleWebhook are registered in Spree::Dependencies and can be replaced with custom implementations:
# config/initializers/spree.rb
Spree::Dependencies.carts_complete_service = 'MyApp::CustomCartComplete'
Spree::Dependencies.payments_handle_webhook_service = 'MyApp::CustomWebhookHandler'