Rails Stripe Billing: Subscriptions, Webhooks, Proration and Dunning That Survive Production
Rails Stripe billing done right. Subscription lifecycle, idempotent webhooks, proration, dunning, and the gotchas that quietly cost SaaS founders thousands.
A SaaS founder forwarded me his Stripe dashboard on a Tuesday afternoon and asked why his monthly revenue had dropped by eleven percent overnight. The number in Stripe was correct. The number in his Rails app was eleven percent too high, because his app had been quietly marking subscriptions as active whenever a checkout session completed, and Stripe had been quietly marking the same subscriptions as past_due two weeks later when the second invoice failed. Nobody on the team noticed because nobody had wired up invoice.payment_failed. Rails Stripe billing is one of those integrations that looks done after you ship the checkout button and is only thirty percent done in reality.
After nineteen years of Rails and a decade of helping founders untangle their billing stacks, I have come to treat Stripe the same way I treat the database — boring, load-bearing infrastructure that you need to get right the first time because every retrofit is migration pain. This post is the Rails Stripe billing stack I now ship by default: the data model, the webhook handler, the subscription lifecycle, the proration logic, and the dunning flow that keeps customers paying after their card expires.
What Rails Stripe Billing Actually Has To Handle
The checkout button is the easy part. The hard part is everything that happens after the first successful payment, and Stripe makes that part hard on purpose because billing is genuinely complicated. A production Rails Stripe billing integration has to deal with at least seven distinct events: a successful first payment, a failed payment, a renewal, a plan change with proration, a cancellation at period end, an immediate cancellation, and a refund. Each of those arrives as one or more webhook events, and any one of them can arrive twice, out of order, or six hours late.
The trap most teams fall into is treating Stripe as a request-response API. They call Stripe::Checkout::Session.create, redirect the user, mark the subscription as active on return, and ship. That works on the happy path and fails the first time a card declines on renewal, the first time a customer downgrades mid-cycle, and the first time Stripe retries a webhook because your app returned a 500. The fix is to treat Stripe as the source of truth for billing state and your Rails database as a cached read-side projection of what Stripe says. Every change to the projection comes from a webhook, never from a controller action.
The Data Model For Rails Stripe Billing
Start with three tables: customers, subscriptions, and a webhook event log. Resist the temptation to put a stripe_status column on your users table and call it done — you will outgrow it the first time a customer has two subscriptions or one subscription is paused.
# db/migrate/20260628000001_create_stripe_billing.rb
class CreateStripeBilling < ActiveRecord::Migration[8.0]
def change
create_table :stripe_customers do |t|
t.references :account, null: false, foreign_key: true, index: { unique: true }
t.string :stripe_customer_id, null: false
t.string :default_payment_method
t.string :email
t.timestamps
end
add_index :stripe_customers, :stripe_customer_id, unique: true
create_table :stripe_subscriptions do |t|
t.references :account, null: false, foreign_key: true
t.string :stripe_subscription_id, null: false
t.string :stripe_price_id, null: false
t.string :status, null: false # trialing, active, past_due, canceled, ...
t.integer :quantity, null: false, default: 1
t.datetime :current_period_start
t.datetime :current_period_end
t.datetime :cancel_at
t.datetime :canceled_at
t.boolean :cancel_at_period_end, null: false, default: false
t.timestamps
end
add_index :stripe_subscriptions, :stripe_subscription_id, unique: true
add_index :stripe_subscriptions, [:account_id, :status]
create_table :stripe_webhook_events do |t|
t.string :stripe_event_id, null: false
t.string :event_type, null: false
t.string :status, null: false, default: "received"
t.jsonb :payload, null: false, default: {}
t.string :error_message
t.datetime :processed_at
t.timestamps
end
add_index :stripe_webhook_events, :stripe_event_id, unique: true
add_index :stripe_webhook_events, [:event_type, :created_at]
end
end
The unique index on stripe_event_id is the cheapest idempotency check you will ever write. Stripe will retry every webhook until you return a 2xx, and “retry” includes “send the same event id again four hours later because your previous response timed out”. One unique constraint plus an ON CONFLICT DO NOTHING insert and you have idempotency for free.
The cancel_at_period_end boolean is worth flagging separately. Stripe represents “cancel at the end of the current period” as a flag on an otherwise active subscription, not as a status of its own, and the day this matters is the day a customer emails you on day twenty-nine saying they want to keep the subscription. If you do not have the flag in your projection, you cannot show “your subscription will end on the 30th” in the UI, and you cannot offer an in-app “actually, never mind” button.
Receiving Stripe Webhooks In Rails
The webhook controller is three jobs: verify the signature, persist the event idempotently, and hand off to a background processor. Do not try to be clever and process the event inline — Stripe gives you ten seconds to return a 2xx, and your User.find_by plus three downstream API calls will eventually blow through that on a Monday morning.
# app/controllers/stripe/webhooks_controller.rb
class Stripe::WebhooksController < ActionController::API
WEBHOOK_SECRET = Rails.application.credentials.dig(:stripe, :webhook_secret)
def create
payload = request.raw_post
signature = request.headers["Stripe-Signature"]
event = Stripe::Webhook.construct_event(payload, signature, WEBHOOK_SECRET)
record = StripeWebhookEvent.create!(
stripe_event_id: event.id,
event_type: event.type,
payload: event.to_hash
)
Stripe::ProcessWebhookJob.perform_later(record.id)
head :ok
rescue ActiveRecord::RecordNotUnique
head :ok
rescue Stripe::SignatureVerificationError, JSON::ParserError
head :bad_request
end
end
Two details earn their keep. Catching ActiveRecord::RecordNotUnique and returning :ok is what makes the endpoint genuinely idempotent — a duplicate delivery is not an error, it is the system working as designed. Returning :bad_request on signature failure (not :unauthorized) is what stops a misconfigured retry loop from hammering your endpoint forever. The same idempotency patterns apply to every webhook integration; if you have not read it yet, my Rails webhook processing post goes deeper on signature verification and replay protection.
Processing The Subscription Lifecycle
The background job is where the actual logic lives. Stripe sends dozens of event types, but for a typical SaaS subscription you only care about six or seven. Route each one to a handler, keep the handlers small, and write them so they can run twice without breaking anything.
# app/jobs/stripe/process_webhook_job.rb
class Stripe::ProcessWebhookJob < ApplicationJob
queue_as :stripe
retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 5
HANDLERS = {
"customer.subscription.created" => Stripe::Handlers::SubscriptionUpserted,
"customer.subscription.updated" => Stripe::Handlers::SubscriptionUpserted,
"customer.subscription.deleted" => Stripe::Handlers::SubscriptionDeleted,
"invoice.payment_succeeded" => Stripe::Handlers::PaymentSucceeded,
"invoice.payment_failed" => Stripe::Handlers::PaymentFailed,
"customer.updated" => Stripe::Handlers::CustomerUpdated
}.freeze
def perform(event_id)
event = StripeWebhookEvent.find(event_id)
return if event.status == "processed"
handler = HANDLERS[event.event_type]
return event.update!(status: "ignored", processed_at: Time.current) unless handler
handler.new(event.payload).call
event.update!(status: "processed", processed_at: Time.current)
rescue => e
event.update!(status: "failed", error_message: e.message)
raise
end
end
The SubscriptionUpserted handler does the work most teams get wrong. Both created and updated flow through the same code path because Stripe sometimes sends updated before created if a webhook delivery is delayed and a plan change happens during the gap. Upsert from the Stripe payload, do not assume an order.
# app/services/stripe/handlers/subscription_upserted.rb
class Stripe::Handlers::SubscriptionUpserted
def initialize(payload)
@subscription = payload.dig("data", "object")
end
def call
customer = StripeCustomer.find_by!(stripe_customer_id: @subscription["customer"])
record = StripeSubscription.find_or_initialize_by(
stripe_subscription_id: @subscription["id"]
)
record.assign_attributes(
account_id: customer.account_id,
stripe_price_id: @subscription["items"]["data"].first["price"]["id"],
status: @subscription["status"],
quantity: @subscription["items"]["data"].first["quantity"],
current_period_start: Time.at(@subscription["current_period_start"]),
current_period_end: Time.at(@subscription["current_period_end"]),
cancel_at: timestamp(@subscription["cancel_at"]),
canceled_at: timestamp(@subscription["canceled_at"]),
cancel_at_period_end: @subscription["cancel_at_period_end"]
)
record.save!
Account.find(customer.account_id).refresh_entitlements!
end
private
def timestamp(value)
value && Time.at(value)
end
end
refresh_entitlements! is the line that ties billing back to your product. It runs the logic that decides which features the account has access to based on the current subscription status. Keep it pure — read from stripe_subscriptions, write to a features cache or a memoized method, never call Stripe from inside it. If you are running multi-tenant feature gates, my Rails Pundit multi-tenant authorization post covers the policy side of the same problem.
Proration: The Part That Surprises Everyone
Proration is what Stripe does when a customer changes plans mid-cycle. It is also the part where founders discover that Stripe and their Rails app disagree about how much a customer owes. The default behavior is create_prorations — Stripe immediately calculates the difference between the old plan’s unused time and the new plan’s prorated cost, and adds it as a line item on the next invoice. That is usually what you want, but it is rarely what your customers expect.
The two knobs that matter are proration_behavior and billing_cycle_anchor. Set the first to "create_prorations" for upgrades (charge the difference immediately on the next invoice) and "none" for downgrades that should take effect at period end. Set billing_cycle_anchor to "unchanged" unless you want the customer’s renewal date to reset, which almost no SaaS actually wants.
# app/services/stripe/change_plan.rb
class Stripe::ChangePlan
def initialize(subscription:, new_price_id:, upgrade:)
@subscription = subscription
@new_price_id = new_price_id
@upgrade = upgrade
end
def call
Stripe::Subscription.update(
@subscription.stripe_subscription_id,
items: [{
id: stripe_item_id,
price: @new_price_id
}],
proration_behavior: @upgrade ? "create_prorations" : "none",
billing_cycle_anchor: "unchanged",
payment_behavior: "error_if_incomplete"
)
end
private
def stripe_item_id
Stripe::Subscription.retrieve(@subscription.stripe_subscription_id).items.data.first.id
end
end
payment_behavior: "error_if_incomplete" is the line that prevents a downgrade from quietly succeeding when the customer’s card is broken. Without it, Stripe will accept the change, create an invoice the customer cannot pay, and your projection will show the wrong plan until the dunning flow either resolves or cancels the subscription. Fail fast at the API call and your UI can show a sensible error.
Dunning: Keeping Customers Paying After Their Card Expires
Dunning is the polite industry term for “the customer’s card declined and now we have to chase them”. On a B2C app the right move is to outsource it entirely to Stripe Billing’s Smart Retries feature — configure three retry attempts over two weeks, set the dunning email template, and let Stripe do its job. On a B2B app you usually want more control, because the customer who needs an updated invoice for accounts payable is the same customer whose card is on file with their CFO’s assistant and not their CEO.
Either way, the two webhook events to handle are invoice.payment_failed and customer.subscription.updated with a status of past_due or unpaid. The first tells you a single attempt failed, the second tells you Stripe has exhausted its retries. Use the first for in-app banners and notifications and the second for actually downgrading the account.
# app/services/stripe/handlers/payment_failed.rb
class Stripe::Handlers::PaymentFailed
def initialize(payload)
@invoice = payload.dig("data", "object")
end
def call
customer = StripeCustomer.find_by!(stripe_customer_id: @invoice["customer"])
account = Account.find(customer.account_id)
BillingMailer.payment_failed(
account: account,
amount_due: @invoice["amount_due"],
hosted_url: @invoice["hosted_invoice_url"],
next_attempt: timestamp(@invoice["next_payment_attempt"])
).deliver_later
account.update!(billing_alert_active: true)
end
private
def timestamp(value)
value && Time.at(value)
end
end
Two production lessons. Send hosted_invoice_url in the email, not a link into your own app — Stripe hosts a payment update page that handles SCA, 3D Secure, and bank redirects correctly, and you will not match it. Run the actual downgrade off the subscription status update, not the payment failure, because Stripe might recover on retry three and you do not want to have downgraded the account in between.
Testing Rails Stripe Billing Without Going Insane
Two tools make billing tests bearable. stripe-mock runs Stripe’s API locally with the same OpenAPI schema as production, so your service objects can call Stripe::Subscription.create in tests without making real network calls. The Stripe CLI’s stripe trigger command fires real-shaped webhook events at your local server, which is the only honest way to test signature verification end to end.
# spec/services/stripe/handlers/subscription_upserted_spec.rb
require "rails_helper"
RSpec.describe Stripe::Handlers::SubscriptionUpserted do
let(:account) { create(:account) }
let(:customer) { create(:stripe_customer, account: account, stripe_customer_id: "cus_test") }
it "upserts the subscription idempotently" do
payload = JSON.parse(file_fixture("stripe/subscription_created.json").read)
2.times { described_class.new(payload).call }
expect(StripeSubscription.where(account: account).count).to eq(1)
expect(account.reload).to be_active_subscription
end
end
Running the handler twice in the same test is not paranoia — it is the cheapest possible smoke test for the property that matters most. If your handler is not idempotent, the test fails before the webhook ever reaches production.
Frequently Asked Questions About Rails Stripe Billing
How should I store Stripe customer ids in Rails?
Put the stripe_customer_id on a dedicated stripe_customers table joined to your accounts (or users, or organizations) table with a unique foreign key. Do not put it on the user record directly — you will eventually have B2B accounts where multiple users share a single billing relationship, and refactoring at that point is painful. Index stripe_customer_id uniquely so webhook handlers can look up customers in a single query.
Do I need to verify Stripe webhook signatures in Rails?
Yes, always. Without signature verification anyone who guesses your webhook URL can mark subscriptions as paid in your database. Use Stripe::Webhook.construct_event with the raw request body and your STRIPE_WEBHOOK_SECRET, and reject events that fail verification with a 400. Combine signature verification with a unique index on stripe_event_id to get both authenticity and idempotency.
What is the difference between Stripe Checkout and Stripe Billing in Rails?
Stripe Checkout is a hosted page for collecting the first payment and creating a subscription — it is the easy way to get from “I have a Rails app” to “I have my first paid customer”. Stripe Billing is the subscription engine that handles renewals, proration, invoices, and dunning after that first payment. Most Rails apps use Checkout for the initial signup and rely on Billing’s webhook events for everything that follows.
How do I handle Stripe subscription proration in Rails?
Let Stripe do the math. When a customer changes plans, call Stripe::Subscription.update with proration_behavior: "create_prorations" for upgrades and "none" for downgrades that should take effect at period end. Set billing_cycle_anchor: "unchanged" so the renewal date does not reset. Read the resulting invoice line items via the invoice.created webhook if you need to display the prorated amount in your own UI before the customer is charged.
Rails Stripe billing is one of those problems where the difference between “works on the happy path” and “works in production” is roughly two weeks of webhook handling, idempotency, and dunning logic. Build the stack once, treat Stripe as the source of truth, and your monthly revenue numbers in Rails will finally agree with the numbers in your Stripe dashboard.
Need help wiring up Stripe Billing on your Rails app, or untangling a billing integration that has drifted from reality? TTB Software specializes in Rails SaaS infrastructure for founders and growth-stage teams. We’ve been doing this for nineteen years.
Related Articles
Rails LLM Cost Tracking: Per-Tenant Spend, Budget Caps, and Real-Time Quota Enforcement
Rails LLM cost tracking that survives a $40k surprise. Per-tenant token accounting, budget caps, quota enforcement, a...
Rails Counter Cache: Eliminate N+1 COUNT Queries Without the Production Gotchas
Rails counter cache kills N+1 COUNT queries on has_many associations. Set it up properly, reset stale counters, and d...
Rails ActionMailer Production Guide: Email Deliverability, Modern APIs, and Bulletproof Testing
Rails ActionMailer production setup: Resend, Postmark, or SendGrid, inbox-reliable delivery, bounce handling, deliver...