Overview
This guide walks you through building a custom payment method integration for Spree. By the end, you’ll have a fully functional payment gateway that:
- Appears as a payment option during checkout
- Uses Payment Sessions for PCI-compliant payment collection
- Supports 3D Secure and alternative payment methods
- Handles webhooks for reliable payment confirmation
- Optionally supports saving payment methods for future use (Payment Setup Sessions)
Before starting, make sure you understand how payments work in Spree.
Step 1: Create the Payment Method Model
Create a new model inheriting from Spree::PaymentMethod. This is the central class that defines how your gateway processes payments.
class MyGateway < Spree::PaymentMethod
preference :api_key, :string
preference :publishable_key, :string
preference :webhook_secret, :string
def payment_icon_name
'my-gateway'
end
end
Step 2: Register the Payment Method
Add your gateway to the list of available payment methods so it appears in the admin panel:
config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.payment_methods << MyGateway
end
After restarting your server, you can select “MyGateway” when creating a new payment method in the admin panel under Settings > Payments.
Step 3: Add Payment Session Support
Payment Sessions are the modern, PCI-compliant way to handle payments. Your gateway creates a session with the provider, the frontend collects payment details using the provider’s SDK, and Spree records the result.
Define the STI Subclass
Create a Payment Session subclass for your gateway. This uses Single Table Inheritance (STI) on the spree_payment_sessions table:
app/models/spree/payment_sessions/my_gateway.rb
module Spree
module PaymentSessions
class MyGateway < Spree::PaymentSession
# Add gateway-specific helper methods here
def client_secret
external_data['client_secret']
end
end
end
end
Implement Session Methods on the Gateway
Override the key methods on your PaymentMethod subclass:
class MyGateway < Spree::PaymentMethod
preference :api_key, :string
preference :publishable_key, :string
preference :webhook_secret, :string
# Tell Spree this gateway uses Payment Sessions
def session_required?
true
end
# Return the STI subclass
def payment_session_class
Spree::PaymentSessions::MyGateway
end
# No source required upfront — it's created when the session completes
def source_required?
false
end
# Create a session with your payment provider
def create_payment_session(order:, amount: nil, external_data: {})
total = amount || order.total_minus_store_credits
# Call your provider's API to create a payment session
provider_session = MyProvider::Client.new(preferred_api_key).create_session(
amount: (total * 100).to_i, # amount in cents
currency: order.currency,
metadata: { order_number: order.number }
)
payment_sessions.create!(
order: order,
amount: total,
currency: order.currency,
external_id: provider_session.id,
external_data: {
client_secret: provider_session.client_secret
},
customer: order.user
)
end
# Update an existing session (e.g., when order total changes)
def update_payment_session(payment_session:, amount: nil, external_data: {})
if amount.present?
MyProvider::Client.new(preferred_api_key).update_session(
payment_session.external_id,
amount: (amount * 100).to_i
)
payment_session.update!(amount: amount)
end
payment_session
end
# Complete the session after the customer pays on the frontend.
#
# Responsibilities:
# - Verify payment status with the provider
# - Create the Spree::Payment record
# - Transition the payment to the correct state
# - Mark the session as completed/failed
#
# Must NOT complete the order — that is handled by Carts::Complete
# (called by the frontend or by the webhook handler).
def complete_payment_session(payment_session:, params: {})
# Verify the payment with your provider
result = MyProvider::Client.new(preferred_api_key).retrieve_session(
payment_session.external_id
)
if result.status == 'succeeded'
payment_session.process!
# Create the Spree::Payment record
payment = payment_session.find_or_create_payment!
# Transition the payment to completed
if payment.present? && !payment.completed?
payment.started_processing! if payment.checkout?
payment.complete! if payment.can_complete?
end
payment_session.complete!
else
payment_session.fail!
end
end
def payment_icon_name
'my-gateway'
end
end
How the Frontend Uses It
The frontend creates a session, then uses the provider’s SDK to collect payment:
// 1. Get available payment methods
const methods = await client.carts.paymentMethods.list(cart.id, options)
// 2. Find your gateway (check session_required flag)
const myGateway = methods.find(m => m.session_required)
// 3. Create a payment session (after shipping is selected so amount is correct)
const session = await client.carts.paymentSessions.create(cart.id, {
payment_method_id: myGateway.id,
}, options)
// 4. Use the client_secret with your provider's frontend SDK
const result = await MyProviderSDK.confirmPayment(session.external_data.client_secret)
// 5. Complete the payment session (creates Payment record, does NOT complete order)
const completed = await client.carts.paymentSessions.complete(
cart.id, session.id,
{ session_result: result.id },
options
)
// 6. Complete the order
const order = await client.carts.complete(cart.id, options)
Important: Always create the payment session after shipping is selected. If the order total changes (shipping rate change, coupon applied), create a new payment session with the updated amount. The complete call in step 5 only handles payment — step 6 finalizes the order.
Step 4: Handle Webhooks (Recommended)
Webhooks ensure payments are captured even if the customer closes their browser after paying. Spree provides a generic webhook endpoint at POST /api/v3/webhooks/payments/:payment_method_id — you don’t need to create your own controller or routes.
Your gateway just needs to implement parse_webhook_event to normalize the provider-specific payload:
class MyGateway < Spree::PaymentMethod
# ... existing code ...
# Parse incoming webhook from your payment provider.
#
# @param raw_body [String] the raw request body
# @param headers [Hash] the request headers
# @return [Hash, nil] normalized result, or nil for unsupported events
# @raise [Spree::PaymentMethod::WebhookSignatureError] if signature is invalid
def parse_webhook_event(raw_body, headers)
# 1. Verify the webhook signature
signature = headers['HTTP_X_SIGNATURE']
unless MyProvider::Webhook.verify(raw_body, signature, preferred_webhook_secret)
raise Spree::PaymentMethod::WebhookSignatureError
end
# 2. Parse the payload
event = JSON.parse(raw_body)
# 3. Return a normalized result
case event['type']
when 'payment.succeeded'
session = Spree::PaymentSession.find_by(external_id: event.dig('data', 'session_id'))
return nil unless session
{ action: :captured, payment_session: session }
when 'payment.failed'
session = Spree::PaymentSession.find_by(external_id: event.dig('data', 'session_id'))
return nil unless session
{ action: :failed, payment_session: session }
else
nil # Unsupported event — Spree will return 200 OK and ignore it
end
end
end
That’s it. Spree handles the rest:
- Verifies the signature synchronously (returns
401 if WebhookSignatureError is raised)
- Enqueues a background job (
Spree::Payments::HandleWebhookJob) for async processing
- Returns
200 OK immediately to prevent provider retries
- The job creates/updates the
Payment record, marks the session completed, and completes the order
The webhook URL for your payment method is available via payment_method.webhook_url — register this with your provider during setup.
Supported Webhook Actions
| Action | Description |
|---|
:captured | Payment was captured (charged). Creates payment and completes order. |
:authorized | Payment was authorized (not yet captured). Creates payment and completes order. |
:failed | Payment failed. Marks the session as failed. |
:canceled | Payment was canceled. Marks the session as canceled. |
Step 5: Control Payment Method Visibility (Optional)
Override visibility methods to control where and when your payment method appears:
class MyGateway < Spree::PaymentMethod
# Only show for stores that support specific currencies
def available_for_store?(store)
store.supported_currencies.include?('EUR')
end
# Only show for orders above a certain amount
def available_for_order?(order)
order.total > 10 && order.currency == 'EUR'
end
end
Step 6: Add Payment Setup Session Support (Optional)
If your provider supports saving payment methods for future use (like Stripe’s SetupIntent), you can add Payment Setup Session support:
class MyGateway < Spree::PaymentMethod
# ... existing code ...
def setup_session_supported?
true
end
def payment_setup_session_class
Spree::PaymentSetupSessions::MyGateway
end
def create_payment_setup_session(customer:, external_data: {})
provider_setup = MyProvider::Client.new(preferred_api_key).create_setup_session(
customer_id: find_or_create_provider_customer(customer)
)
payment_setup_sessions.create!(
customer: customer,
external_id: provider_setup.id,
external_client_secret: provider_setup.client_secret
)
end
def complete_payment_setup_session(setup_session:, params: {})
result = MyProvider::Client.new(preferred_api_key).retrieve_setup_session(
setup_session.external_id
)
if result.status == 'succeeded'
# Create a saved payment source
credit_card = Spree::CreditCard.create!(
payment_method: self,
user: setup_session.customer,
gateway_payment_profile_id: result.payment_method_id,
cc_type: result.card_brand,
last_digits: result.card_last4,
month: result.card_exp_month,
year: result.card_exp_year,
name: result.cardholder_name
)
setup_session.update!(
payment_source: credit_card
)
setup_session.complete!
else
setup_session.fail!
end
setup_session
end
end
And the STI subclass:
app/models/spree/payment_setup_sessions/my_gateway.rb
module Spree
module PaymentSetupSessions
class MyGateway < Spree::PaymentSetupSession
end
end
end
Gateway Options Reference
For every gateway action (authorize, purchase, capture, void, credit), Spree passes a hash of gateway options:
| Option | Description |
|---|
email and customer | The email address related to the order |
ip | The last IP address for the order |
order_id | The Order’s number attribute, plus the identifier for each payment |
shipping | The total shipping cost for the order, in cents |
tax | The total tax cost for the order, in cents |
subtotal | The item total for the order, in cents |
currency | The 3-character currency code for the order |
discount | The promotional discount applied to the order |
billing_address | A hash containing billing address information |
shipping_address | A hash containing shipping address information |