Skip to main content

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.
app/models/my_gateway.rb
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:
app/models/my_gateway.rb
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.
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:
app/models/my_gateway.rb
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

ActionDescription
:capturedPayment was captured (charged). Creates payment and completes order.
:authorizedPayment was authorized (not yet captured). Creates payment and completes order.
:failedPayment failed. Marks the session as failed.
:canceledPayment 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:
app/models/my_gateway.rb
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:
app/models/my_gateway.rb
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:
OptionDescription
email and customerThe email address related to the order
ipThe last IP address for the order
order_idThe Order’s number attribute, plus the identifier for each payment
shippingThe total shipping cost for the order, in cents
taxThe total tax cost for the order, in cents
subtotalThe item total for the order, in cents
currencyThe 3-character currency code for the order
discountThe promotional discount applied to the order
billing_addressA hash containing billing address information
shipping_addressA hash containing shipping address information