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
moduleBilling# Opens Stripe's hosted billing portal where a customer can update their card,# change plan, or cancel. Returns the Stripe session to redirect to.classBillingPortalSessiondefinitialize(user:,return_url:)@user=user@return_url=return_urlenddefcallStripe::BillingPortal::Session.create(customer: Billing::Customer.new(user).ensure!,return_url: return_url)endprivateattr_reader:user,:return_urlendend
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:
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.
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)
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.
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
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:allow_other_host: trueis not optional. Rails 8 raisesActionController::Redirecting::UnsafeRedirectErroron a redirect to any other host unless the call explicitly permits it, andstripe.comis another host.status: :see_otheris 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.createneeds a customer, and a Rails application that sold something through Checkout does not automatically have one.Checkout in
paymentmode has defaulted tocustomer_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!:Idempotent by construction: the column is the cache, and the API call happens once per user, ever. The
metadatais 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
Createand nothing else, so there is noStripe::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 atreturn_urlonly 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.updatedfor a plan change,customer.subscription.deletedfor a cancellation,payment_method.attachedfor 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:
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:
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::Configurationso 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
flowfor 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.