How do I let customers cancel their own subscription?
Send them to the Stripe billing portal rather than building a cancellation UI. One Shot ships the route already: a form that posts to the billing portal controller and redirects to Stripe. When they cancel there, customer.subscription.deleted arrives and the local row is updated, so access ends without you writing any of it.
What ships
The portal route is in config/routes.rb and the controller creates a session with Stripe and
redirects. The settings page links to it. A customer can update their card, download invoices,
change plan and cancel, all on Stripe's pages, and all of it is PCI-handled by them.
The two things that make the redirect work
This is the part people lose an afternoon to, because both failures are silent.
The content security policy. The policy is enforced, and form-action covers the whole
navigation a form starts, including the redirect. Stripe's host has to be listed in
config/initializers/content_security_policy.rb or the button does nothing: no exception, no flash,
nothing in the network tab.
Turbo. A form that redirects off-site needs data-turbo="false" on the form or the submit
button. Without it Turbo submits via fetch and tries to follow the cross-origin redirect as an
XHR, which connect-src blocks. Same dead-looking button, different layer.
Both are pinned by specs so a regression fails the build rather than the button.
Cancellation is not immediate, and that is correct
Stripe cancels at period end by default. The customer keeps access until the date they already paid
for, then customer.subscription.deleted fires and the local Subscription row moves to canceled.
Entitlement reads that row, so the gate closes on its own.
If you want immediate cancellation with a prorated refund, that is a portal configuration setting on Stripe's side, not a code change.
Verify the round trip
bin/dev
With no STRIPE_SECRET_KEY set, the local fake runs the same flow in process, so you can click
through cancellation without a Stripe account. Then repeat against a test-mode key to exercise the
real webhook.
Do not build your own cancel button
It is tempting, because it is three lines to set a status column. The problem is that it lies: the Stripe subscription keeps billing, the customer keeps being charged, and your app says they canceled. Cancel through Stripe and let the webhook tell you it happened.