Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 060 / 100

Payments with Pay and Stripe for Ruby on Rails developers

Subscriptions in Rails apps.

Expona Admin6 min read

Payments with Pay and Stripe for Ruby on Rails developers

Handling recurring billing in a Rails app can feel like you're reinventing the wheel every time. The Pay gem abstracts away much of that friction, giving you a unified interface to Stripe, Braintree, and other payment processors without rebuilding subscription logic from scratch. In this post, we'll walk through setting up subscriptions, handling webhooks, and integrating customer self-service portals—all patterns we've deployed across multiple production SaaS applications.

Understanding the Pay gem and why it matters

The Pay gem is maintained by the Rails community and sits between your application and Stripe (or other processors). Rather than making raw Stripe API calls scattered throughout your codebase, Pay gives you Rails-like models and callbacks that feel native to your app.

A typical flow looks like this: a user chooses a plan, enters payment details, and subscribes. Pay creates a Subscription record, syncs with Stripe, and fires webhook events when Stripe tells you the payment succeeded, failed, or the subscription was canceled. Your app responds to those events and updates user access accordingly.

Why not call Stripe directly? You could, but you'd spend time handling idempotency, state reconciliation, and error recovery. The Pay gem handles these concerns and gives you a cleaner test story too.

Setting up Pay with Stripe in your Rails app

Start by adding Pay to your Gemfile:

ruby
gem 'pay', '~> 6.0'

Run the generator to create migrations and an initializer:

ruby
rails generate pay:install
rails db:migrate

This creates a pay_customers table and a pay_subscriptions table. The Pay gem adds methods to your User model (or any model you configure) so you can do things like user.pay_customer and user.subscription.

Next, configure Pay in config/initializers/pay.rb:

ruby
Pay.setup do |config|
  config.app_name = "YourApp"
  config.processor = :stripe
  config.stripe_public_key = ENV["STRIPE_PUBLIC_KEY"]
  config.stripe_secret_key = ENV["STRIPE_SECRET_KEY"]
  config.webhook_secret = ENV["STRIPE_WEBHOOK_SECRET"]
end

Tell Pay which model is your customer (usually User):

ruby
class User < ApplicationRecord
  pay_customer
end

That's the foundation. Now your users can create subscriptions, and Pay will handle the plumbing with Stripe.

Creating subscriptions through Pay

When a user upgrades, you need to create a subscription. In a typical scenario, you have plan data—a plan ID or price ID from Stripe—and you're ready to charge. Here's a controller action:

ruby
class SubscriptionsController < ApplicationController
  def create
    plan = params[:plan]
    subscription = current_user.pay_customer.subscribe(
      name: 'default',
      plan: plan,
      trial_period_days: 14
    )
    
    if subscription.active?
      redirect_to dashboard_path, notice: 'Subscription created'
    else
      redirect_to billing_path, alert: 'Subscription failed'
    end
  end
end

The subscribe method returns a Pay subscription object. It's already created in your database, and Pay has told Stripe about it. If the payment method fails, subscription.active? returns false, and you can prompt the user to retry or update their card.

For trials, pass trial_period_days. The subscription is marked as active immediately, but won't be charged until the trial ends—Stripe handles the scheduling.

Handling webhooks to keep state in sync

Stripe sends events to your app whenever something happens: payment succeeded, subscription renewed, payment failed, customer canceled. You must listen for these and update your local subscription state. Pay makes this straightforward.

Pay generates a webhook controller at app/controllers/pay/webhooks_controller.rb. By default, it only logs events. You extend it with handlers:

ruby
module Pay
  class WebhooksController < Pay::ApplicationController
    def customer_subscription_created
      subscription = pay_subscription
      subscription.update(status: 'active')
    end

    def customer_subscription_updated
      subscription = pay_subscription
      # Handle plan changes, pause requests, etc.
      subscription.update(status: subscription.active? ? 'active' : 'paused')
    end

    def customer_subscription_deleted
      subscription = pay_subscription
      subscription.update(status: 'canceled')
      subscription.user.update(plan: nil)
    end

    def charge_succeeded
      subscription = pay_subscription
      # Log successful payment, send receipt email, etc.
      BillingMailer.payment_receipt(subscription).deliver_later
    end

    def charge_failed
      subscription = pay_subscription
      # Notify user, prompt for card update
      AlertMailer.payment_failed(subscription.user).deliver_later
    end
  end
end

Make sure your Stripe dashboard is configured to send webhooks to your app. In development, use the Stripe CLI to forward events locally:

ruby
stripe listen --forward-to localhost:3000/pay/webhooks

The webhook path is set up automatically by Pay, and events are verified using your webhook secret. Pay handles signature validation, so you don't need to worry about malicious payloads.

Building a customer portal for self-service

Users often want to update their payment method, pause a subscription, or see invoices without contacting support. Stripe's customer portal handles this beautifully. Pay gives you a link in one line:

ruby
class BillingController < ApplicationController
  def show
    @portal_url = current_user.pay_customer.billing_portal_url(
      return_url: dashboard_url
    )
  end
end

In your view:

ruby
<%= link_to 'Manage Billing', @portal_url, class: 'btn btn-primary' %>

Users click that link, authenticate with Stripe, and can update cards, cancel subscriptions, and download invoices. Everything syncs back to your Pay tables via webhooks, so your app always knows the current subscription state.

You can customize the portal in your Stripe dashboard: add a logo, choose which features users can access, and set the timezone for billing dates. The portal respects your Stripe configuration, so it's a reflection of your product's billing policy.

Testing subscriptions with Stripe test mode

Stripe provides test card numbers that simulate different outcomes. In development and test environments, use your Stripe test keys (not live keys). Then use test cards like:

  • 4242 4242 4242 4242 – success
  • 4000 0000 0000 0002 – declined
  • 3782 822463 10005 – expired card

Write tests for your subscription flow:

ruby
require 'test_helper'

class SubscriptionTest < ActiveSupport::TestCase
  setup do
    @user = users(:one)
    Pay.setup do |config|
      config.stripe_secret_key = ENV['STRIPE_TEST_SECRET']
    end
  end

  test 'user can subscribe with valid card' do
    subscription = @user.pay_customer.subscribe(plan: 'price_test123')
    assert subscription.active?
    assert_equal subscription.user_id, @user.id
  end

  test 'webhook updates subscription status' do
    subscription = @user.pay_customer.subscribe(plan: 'price_test123')
    
    # Simulate Stripe webhook: subscription canceled
    Pay::StripeEvent.new.subscription_updated(
      { id: subscription.processor_id, status: 'canceled' }
    )
    
    subscription.reload
    assert_equal subscription.status, 'canceled'
  end
end

For integration tests, many teams mock Stripe API calls using VCR or Webmock. This keeps your CI fast and deterministic. In staging, you can use real Stripe test credentials but with test cards, giving you confidence before production.

Never commit live API keys to version control. Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.).

Common mistakes

  • Ignoring webhook events: If you only update subscription state when the user visits a page, you'll miss state changes. Webhooks are the source of truth for payment events. Act on them immediately.
  • Forgetting idempotency: Webhooks can be delivered twice. Wrap webhook handlers in idempotency checks—use a unique event ID to detect duplicates and skip processing.
  • Mixing test and live keys: A common slip-up is using a live secret key in development. This burns through real charges. Use environment-specific keys and validate them on startup.
  • Not testing webhook failure scenarios: Your code should handle payment failures gracefully. Test what happens when charge.failed fires, not just the happy path.
  • Hardcoding plan IDs: Store Stripe product and price IDs in your database or config, not scattered through your code. It makes it easier to change plans without redeploying.

Key takeaways

  • The Pay gem abstracts Stripe and other processors, giving you Rails-like models and a cleaner API than raw Stripe calls.
  • Use webhooks, not polling or page visits, to keep subscription state in sync with reality.
  • The Stripe customer portal handles self-service billing—subscription management, card updates, invoices—without you building a UI for it.
  • Always use Stripe test mode and test cards during development. Use environment variables for API keys and never commit them.
  • Test your webhook handlers thoroughly, including failure scenarios and idempotency.
  • Store Stripe IDs (products, prices, subscriptions) in your database so you can reference them reliably and update your billing logic without code changes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles