December 18, 2025 · 15 min read
How to Add Stripe Subscriptions to a Rails 8 SaaS in 2026
Jeronim Morina
Founder, Omaship
You've built your Rails 8 app. Now you need to charge money for it. Stripe is the default choice—but integrating subscriptions properly is harder than the docs make it look.
This guide walks through the real decisions you'll face when adding Stripe subscriptions to a Rails 8 SaaS: which approach to use, what to handle in webhooks, how to manage plan changes, and the gotchas that bite you in production.
Skip the integration? Omaship comes with Stripe and Paddle integration pre-configured, including webhooks, subscription lifecycle management, and a checkout flow. See pricing →
1. Choose your approach: gem vs. raw API
Before writing any code, make a decision: use a billing gem or integrate Stripe's API directly. Both work. The trade-offs are real.
Option A: Use the Pay gem
The Pay gem by Chris Oliver (the Jumpstart Pro creator) is the most popular choice in the Rails ecosystem. It handles Stripe, Paddle, Braintree, and Lemon Squeezy with a unified interface.
# Gemfile
gem "pay", "~> 8.0"
gem "stripe", "~> 13.0"
Pros: Quick setup, handles webhook routing, unified API across providers, good Rails integration.
Cons: Abstraction hides details, harder to customize, you depend on the gem's update cycle for new Stripe features.
Option B: Direct Stripe API
Use the stripe gem directly and write your own models, controllers, and webhook handlers. More work upfront, but you own every line.
# Gemfile
gem "stripe", "~> 13.0"
Pros: Full control, no abstraction leaks, immediate access to new Stripe features, easier for AI agents to understand.
Cons: More code to write, you handle all edge cases yourself.
Our recommendation: If you're building a SaaS you plan to sell, go with the direct approach. Acquirers prefer explicit code over gem abstractions. If you need to ship this week and billing isn't your core product, use Pay.
2. Setting up Stripe with Rails 8
Start with the essentials:
# 1. Add the gem
bundle add stripe
# 2. Configure credentials
bin/rails credentials:edit
Add your Stripe keys to Rails credentials:
# config/credentials.yml.enc
stripe:
secret_key: sk_test_...
publishable_key: pk_test_...
webhook_secret: whsec_...
price_monthly: price_...
price_yearly: price_...
Create a simple initializer:
# config/initializers/stripe.rb
Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
Stripe.api_version = "2025-12-18.acacia"
Important: Pin the API version. Stripe evolves fast—if you don't pin, a background API version change could break your integration overnight.
3. Data models for subscriptions
You need to track three things: who the customer is (Stripe Customer ID), what they're paying for (Subscription), and what they can access (Plan/entitlements).
# Migration
class AddStripeFieldsToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :stripe_customer_id, :string
add_index :users, :stripe_customer_id, unique: true
end
end
# Subscriptions table
class CreateSubscriptions < ActiveRecord::Migration[8.0]
def change
create_table :subscriptions do |t|
t.references :user, null: false, foreign_key: true
t.string :stripe_subscription_id, null: false
t.string :stripe_price_id, null: false
t.string :status, null: false, default: "incomplete"
t.datetime :current_period_end
t.datetime :cancel_at
t.timestamps
end
add_index :subscriptions, :stripe_subscription_id, unique: true
end
end
Key design decision: Store the status field from Stripe's subscription object. This is your single source of truth for access control. The possible values: incomplete, active, past_due, canceled, unpaid, trialing, paused.
# app/models/subscription.rb
class Subscription < ApplicationRecord
belongs_to :user
scope :active, -> { where(status: %w[active trialing]) }
def active? = status.in?(%w[active trialing])
def will_cancel? = cancel_at.present?
end
# app/models/user.rb
class User < ApplicationRecord
has_one :subscription
def subscribed? = subscription&.active? || false
def stripe_customer
return Stripe::Customer.retrieve(stripe_customer_id) if stripe_customer_id
customer = Stripe::Customer.create(email: email, metadata: { user_id: id })
update!(stripe_customer_id: customer.id)
customer
end
end
4. Building the checkout flow
In 2026, Stripe Checkout is the right default for most SaaS apps. It handles PCI compliance, 3D Secure, tax calculation, and dozens of payment methods. Don't build a custom checkout form unless you have a very specific reason.
# app/controllers/checkouts_controller.rb
class CheckoutsController < ApplicationController
def create
price_id = params[:price_id]
session = Stripe::Checkout::Session.create(
customer: current_user.stripe_customer.id,
mode: "subscription",
line_items: [{ price: price_id, quantity: 1 }],
success_url: root_url + "?checkout=success",
cancel_url: pricing_url,
subscription_data: {
metadata: { user_id: current_user.id }
}
)
redirect_to session.url, allow_other_host: true
end
end
Notice we pass metadata: { user_id: current_user.id } in the subscription data. This is crucial—when webhooks fire, you'll need to link the Stripe subscription back to your user.
The view is simple:
<%# Pricing page button %>
<%= button_to "Subscribe Monthly",
checkouts_path(price_id: Rails.application.credentials.dig(:stripe, :price_monthly)),
class: "btn-primary" %>
5. Webhooks: the heart of billing
This is the part most tutorials skip—and where most billing integrations break. Your checkout redirects the user back to your app, but the subscription isn't confirmed yet. Stripe confirms it asynchronously via webhooks.
Handle these events at minimum:
checkout.session.completed— initial checkout succeededcustomer.subscription.updated— plan changes, renewals, payment method updatescustomer.subscription.deleted— subscription canceled or expiredinvoice.payment_failed— payment declined
# app/controllers/webhooks/stripe_controller.rb
module Webhooks
class StripeController < ApplicationController
skip_before_action :verify_authenticity_token
skip_before_action :require_authentication
def create
event = construct_event
return head :bad_request unless event
case event.type
when "customer.subscription.created",
"customer.subscription.updated"
handle_subscription_change(event.data.object)
when "customer.subscription.deleted"
handle_subscription_deleted(event.data.object)
when "invoice.payment_failed"
handle_payment_failed(event.data.object)
end
head :ok
end
private
def construct_event
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
secret = Rails.application.credentials.dig(:stripe, :webhook_secret)
Stripe::Webhook.construct_event(payload, sig_header, secret)
rescue Stripe::SignatureVerificationError
nil
end
def handle_subscription_change(stripe_sub)
user = User.find_by(stripe_customer_id: stripe_sub.customer)
return unless user
sub = user.subscription || user.build_subscription
sub.update!(
stripe_subscription_id: stripe_sub.id,
stripe_price_id: stripe_sub.items.data.first.price.id,
status: stripe_sub.status,
current_period_end: Time.at(stripe_sub.current_period_end),
cancel_at: stripe_sub.cancel_at ? Time.at(stripe_sub.cancel_at) : nil
)
end
def handle_subscription_deleted(stripe_sub)
Subscription
.find_by(stripe_subscription_id: stripe_sub.id)
&.update!(status: "canceled")
end
def handle_payment_failed(invoice)
user = User.find_by(stripe_customer_id: invoice.customer)
return unless user
PaymentFailedMailer.notify(user).deliver_later
end
end
end
Add the route:
# config/routes.rb
namespace :webhooks do
resource :stripe, only: :create, controller: "stripe"
end
6. Subscription lifecycle management
After checkout, you need to handle plan upgrades/downgrades, cancellations, and reactivations. Here's a clean service object approach:
# app/services/subscription_manager.rb
class SubscriptionManager
def initialize(user)
@user = user
@subscription = user.subscription
end
def change_plan(new_price_id)
stripe_sub = Stripe::Subscription.retrieve(@subscription.stripe_subscription_id)
Stripe::Subscription.update(stripe_sub.id, {
items: [{ id: stripe_sub.items.data.first.id, price: new_price_id }],
proration_behavior: "create_prorations"
})
end
def cancel
Stripe::Subscription.update(
@subscription.stripe_subscription_id,
{ cancel_at_period_end: true }
)
end
def reactivate
Stripe::Subscription.update(
@subscription.stripe_subscription_id,
{ cancel_at_period_end: false }
)
end
end
Pro tip: Always use cancel_at_period_end: true instead of immediately deleting the subscription. Let users keep access until the end of their billing period. It reduces churn and support tickets.
7. Testing subscriptions
Stripe provides excellent test tooling. Use test mode cards and the Stripe CLI for webhook testing.
# Install Stripe CLI and forward webhooks to localhost
stripe listen --forward-to localhost:3000/webhooks/stripe
Test cards you'll need:
4242 4242 4242 4242— always succeeds4000 0000 0000 3220— requires 3D Secure4000 0000 0000 0341— attaches to customer, but first charge fails4000 0000 0000 9995— always declines
Write integration tests for your webhook handler:
# test/controllers/webhooks/stripe_controller_test.rb
class Webhooks::StripeControllerTest < ActionDispatch::IntegrationTest
test "subscription created webhook creates local subscription" do
user = users(:subscribed)
event = build_stripe_event("customer.subscription.created", {
id: "sub_test_123",
customer: user.stripe_customer_id,
status: "active",
current_period_end: 30.days.from_now.to_i,
items: { data: [{ price: { id: "price_monthly" } }] }
})
post webhooks_stripe_path, params: event.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :ok
assert user.reload.subscribed?
end
end
8. Production gotchas
These are the things that bite you after launch. We've seen all of them.
Webhook ordering is not guaranteed
Stripe may deliver customer.subscription.updated before checkout.session.completed. Design your handlers to be idempotent—they should produce the same result whether called once or ten times, in any order.
Handle dunning (failed payments) gracefully
When a payment fails, don't immediately cut access. Stripe retries several times over days. Show a banner, send an email, but keep the user working. Cutting access instantly guarantees churn.
Currencies and tax
If you sell to the EU, you need to handle VAT. Stripe Tax can automate this, but it costs 0.5% per transaction. For early-stage SaaS, consider using Lemon Squeezy or Paddle as a Merchant of Record—they handle tax for you.
Don't trust the checkout redirect
After checkout, Stripe redirects to your success_url. But the user might close their browser before the redirect. Always rely on webhooks for subscription activation, never on the redirect alone.
Pin your Stripe API version
Already mentioned, but worth repeating. Stripe makes breaking changes between API versions. Pin the version in your initializer and test before upgrading.
Process webhooks in background jobs
In production, move webhook processing to a Solid Queue job. Return 200 OK immediately, enqueue the job, and handle it async. This prevents Stripe from timing out and retrying.
9. Alternatives to Stripe
Stripe is the default, but it's not the only option. Here's when to consider alternatives:
| Provider | Best For | Fee | Tax Handling |
|---|---|---|---|
| Stripe | Maximum flexibility, custom billing | 2.9% + 30¢ | Stripe Tax (0.5% extra) |
| Paddle | EU sellers, tax compliance | 5% + 50¢ | Included (Merchant of Record) |
| Lemon Squeezy | Indie hackers, simplicity | 5% + 50¢ | Included (Merchant of Record) |
Our take: If you're a solo founder selling globally, a Merchant of Record (Paddle or Lemon Squeezy) saves you from tax headaches. If you need maximum control over the billing experience or are US-focused, Stripe is the right call.
Skip the billing integration
Omaship comes with Stripe and Paddle pre-configured. Checkout flow, webhook handling, subscription lifecycle—all included. Ship your SaaS, not your billing code.