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:
gem 'pay', '~> 6.0'Run the generator to create migrations and an initializer:
rails generate pay:install
rails db:migrateThis 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:
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"]
endTell Pay which model is your customer (usually User):
class User < ApplicationRecord
pay_customer
endThat'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:
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
endThe 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:
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
endMake sure your Stripe dashboard is configured to send webhooks to your app. In development, use the Stripe CLI to forward events locally:
stripe listen --forward-to localhost:3000/pay/webhooksThe 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:
class BillingController < ApplicationController
def show
@portal_url = current_user.pay_customer.billing_portal_url(
return_url: dashboard_url
)
end
endIn your view:
<%= 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– success4000 0000 0000 0002– declined3782 822463 10005– expired card
Write tests for your subscription flow:
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
endFor 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.failedfires, 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.






Comments
Loading comments…