LaunchKit

The Stripe customer portal in Rails: one session, no billing UI

September 13, 2026

Of the three things a paying customer eventually wants to do, changing a card, switching plan and cancelling, none is interesting to build and all three are unpleasant to get wrong. Stripe hosts all of them. The Rails side is one API call and a redirect.

The billing hub covers the money coming in. Rails Stripe Checkout is the trip out and back that collects it. This is what happens on the day after, and it is the shortest piece of billing code you will write, provided one thing is already true.

The whole integration

module Billing
  # Opens Stripe's hosted billing portal where a customer can update their card,
  # change plan, or cancel. Returns the Stripe session to redirect to.
  class BillingPortalSession
    def initialize(user:, return_url:)
      @user = user
      @return_url = return_url
    end

    def call
      Stripe::BillingPortal::Session.create(
        customer: Billing::Customer.new(user).ensure!,
        return_url: return_url
      )
    end

    private

    attr_reader :user, :return_url
  end
end

Two arguments. A Stripe billing portal session takes a customer id, and where to send the browser when they are finished. The session comes back carrying a url, and the controller does the only other thing there is to do:

class BillingPortalsController < ApplicationController
  def create
    session = Billing::BillingPortalSession.new(
      user: Current.user,
      return_url: billing_url
    ).call

    redirect_to session.url, allow_other_host: true, status: :see_other
  end
end

allow_other_host: true is not optional. Rails 8 raises ActionController::Redirecting::UnsafeRedirectError on a redirect to any other host unless the call explicitly permits it, and stripe.com is another host. status: :see_other is the right code for a redirect issued from a POST.

That is the integration. There is no card form, no plan comparison screen, no cancellation confirmation, no dunning email, and no VAT-inclusive invoice PDF, because all of those are on the other side of that URL.

The prerequisite that is not obvious

Stripe::BillingPortal::Session.create needs a customer, and a Rails application that sold something through Checkout does not automatically have one.

Checkout in payment mode has defaulted to customer_creation: "if_required" since API version 2022-08-01, which means a one-off purchase can complete, the money can arrive, and no Stripe Customer is created at all. The sister article on Checkout works through what that costs downstream. Here it is the difference between the portal opening and the portal being impossible.

Hence the ensure!:

def ensure!
  return user.stripe_customer_id if user.stripe_customer_id.present?

  customer = Stripe::Customer.create(email: user.email_address, metadata: { user_id: user.id })
  user.update!(stripe_customer_id: customer.id)
  customer.id
end

Idempotent by construction: the column is the cache, and the API call happens once per user, ever. The metadata is the part worth copying. It puts your own primary key on the Stripe object, so the next person debugging a payment in the Stripe dashboard can get back to a row in your database without a lookup table.

A customer created this way has no payment method attached yet. That is fine. The portal is the place where one gets attached.

Create only, which changes what you can know

Checkout sessions can be fetched back, and the return path of a Checkout flow is built on exactly that: retrieve the session by id, read payment_status, believe the server rather than the browser.

Portal sessions do not offer it. The resource extends Create and nothing else, so there is no Stripe::BillingPortal::Session.retrieve.

Think about what that removes. When the browser comes back to your return_url, you hold a request with no session id worth having and no API call that would tell you anything. You cannot ask whether they cancelled. You cannot ask whether they switched plan. You cannot ask whether they did anything at all, because arriving at return_url only means they clicked the link back.

So the return is a navigation event, not information. Everything the customer actually did reaches you as a webhook: customer.subscription.updated for a plan change, customer.subscription.deleted for a cancellation, payment_method.attached for a new card. Stripe webhooks in Rails is where that endpoint gets its signature check and its idempotency, and the portal is the clearest argument for why that endpoint is not optional: without it, a customer can cancel and your application will never find out.

A pragmatic consequence for the return page: do not render "your subscription has been cancelled". Render the billing page, read from your own records, and let the webhook have updated them or not. Anything else is a claim you cannot support.

Sessions expire, so mint them per click

Stripe's own description of the resource is explicit, and it is worth quoting rather than paraphrasing:

For security reasons, sessions are short-lived and will expire if the customer does not visit the URL. Create sessions on-demand when customers intend to manage their subscriptions and billing details.

Three things follow directly. Do not store the URL on a record. Do not put it in an email. Do not generate one while rendering a page, because a page can sit open in a tab for an hour before anyone clicks anything.

Which is why the entry point is a POST to a route that renders nothing:

resource :billing_portal, only: :create    # open Stripe's billing portal (card & invoices)
<%= button_to t("home.index.subscription.payment_method"), billing_portal_path %>

The session is created at the moment of the click, used immediately, and never seen again. The POST also earns its CSRF token, which is the right default anyway: a request that mints something on a third party's servers on behalf of a signed-in user is state-changing, whatever your own database thinks.

The configuration lives somewhere your repository does not

What the customer can actually do in there, cancel or not, switch plan or not, see invoices or not, update their address or not, is a Stripe billing portal configuration, and it is a Stripe object (/v1/billing_portal/configurations), not a Rails one. Omit it on create and Stripe uses the account's default configuration, which is edited in the dashboard.

This is the operational trap. Somebody can turn cancellation on or off in the Stripe dashboard on a Tuesday afternoon, and your application will not know, will not deploy, and will not have a test that fails. The behaviour your customers experience is now defined half in git and half in a web UI with no review process.

Two ways out, and the right one depends on the team. Either treat the dashboard configuration as authoritative and write down, in the repository, which settings the application assumes; or create configurations from code with Stripe::BillingPortal::Configuration so the settings live in version control like everything else. What does not work is leaving it implicit, which is the default and is how a support ticket about a customer who could not cancel becomes a two-day investigation.

The session also accepts a flow for deep-linking straight into one action, so a "cancel subscription" link can open the portal already on the cancellation step rather than at its front page. It is a small thing that removes a real amount of confusion from a moment where confusion costs you a refund request instead of a clean cancellation.

What this actually buys

The portal is the rare integration where the honest measure is what does not exist in the repository: no card form and no PCI surface, no proration arithmetic, no cancellation flow with its retention offer and its effective-date edge cases, no invoice rendering, no VAT display, no localisation of any of it.

What it costs is a dependency on a screen you do not control and cannot style beyond a logo and a colour, and a configuration that lives outside your codebase. For subscription billing on a product that is not itself a billing product, that trade is close to free.

More on Stripe billing in Rails

← All Stripe billing in Rails articles