Omaship

December 18, 2025 · 15 min read

How to Add Stripe Subscriptions to a Rails 8 SaaS in 2026

Jeronim Morina

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 succeeded
  • customer.subscription.updated — plan changes, renewals, payment method updates
  • customer.subscription.deleted — subscription canceled or expired
  • invoice.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 succeeds
  • 4000 0000 0000 3220 — requires 3D Secure
  • 4000 0000 0000 0341 — attaches to customer, but first charge fails
  • 4000 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.

Continue reading

We use analytics and session recordings to learn which parts of Omaship help and which need work. Accept all, or customize what you share.

Privacy policy