Stripe subscriptions in Rails: change, cancel, resume, and one mirror
September 15, 2026
Selling a subscription is one Checkout session.
Rails Stripe Checkout covers that
trip. Keeping one is three operations that a customer expects to be instant and reversible: switch
plan, cancel, change their mind about cancelling.
Stripe subscriptions in Rails end up being less code than people expect, because those three are the
same API call with different arguments. What takes the thought is what happens locally afterwards.
Mutate at Stripe, then hand the returned object to the same class the webhook handler uses. Not
a copy of it, not a similar one. The identical class.
That is the whole architecture, and it buys the thing that makes subscription code survivable: a few
seconds later Stripe sends customer.subscription.updated describing the change you just made, the
webhook runs SubscriptionUpserted on it, and the second pass writes exactly what the first pass
wrote. No ordering problem, no "did the webhook get here yet", no reconciliation job.
It works because the mirror is keyed rather than appended:
find_or_initialize_by on the Stripe id makes the write idempotent by construction. Replaying the
same Stripe object a hundred times leaves one row in the state that object described.
Stripe webhooks in Rails goes
through why that endpoint receives duplicates as a matter of routine rather than as an edge case.
The alternative, writing the local state from your own knowledge of what you just asked for, is
where subscription bugs come from. Stripe may not have done what you asked. It may have done it and
also done something else, like moving the subscription to past_due because the proration charge
failed. Reading the returned object is reading the answer instead of assuming it.
The extra retrieve is not laziness. A subscription's line items have their own ids, and items:
is a write to that collection.
Pass { price: new_price } without an id and Stripe reads it as a new item to add. The
subscription now has two items, the customer is billed for the old plan and the new one together,
and nothing errored. The invoice is where you find out.
So the id has to come from somewhere, and fetching it just in time is the honest option: Stripe owns
it, it can change, and caching it locally means a stale id the first time somebody edits the
subscription in the Stripe dashboard.
Stripe subscription proration is on by default: proration_behavior: "create_prorations" is what
Stripe would do anyway, and it is written out because this is the line someone will want to change.
An upgrade charges the difference for the remainder of the period; a downgrade credits it against
the next invoice. The alternative, none, switches the
plan and bills the new price from the next cycle, which is friendlier on a downgrade and a gift on
an upgrade.
The subscription stays active. Stripe stops renewing it, and when the period closes it sends
customer.subscription.deleted, which flips the mirror to canceled and takes access away then.
Three properties come with that choice, and they are the reason to make it:
The customer keeps what they paid for. No proration, no partial refund to argue about.
Nothing is destroyed, so it is reversible.
The state is legible on screen. A subscription with cancel_at_period_end true and a
current_period_end in the future renders as "ends on the 14th" rather than as a binary.
Cancelling immediately with Stripe::Subscription.cancel is a different product decision: access
stops now, the customer has paid for days they will not get, and you own the refund conversation.
Only while the subscription is still live. Once the period has closed and Stripe has actually
cancelled it, there is nothing to resume: the object is terminal and the customer needs a new
subscription, which means a new Checkout session and a new subscription id.
So a "reactivate" button has to know which of those two situations it is in, and the local mirror
already carries the answer in status and cancel_at_period_end. Offering resume on a terminal
subscription produces a Stripe error in front of somebody who was trying to give you money again.
The field Stripe moved without telling anyone
# Recent Stripe API versions moved `current_period_end` from the subscription onto the# subscription item; `try` reads whichever shape this account's API version returns.defcurrent_period_enditem.try(:current_period_end)||subscription.try(:current_period_end)end
An API version bump is the kind of change that does not announce itself. current_period_end used
to live on the subscription. On newer API versions it lives on the subscription item, and the subscription no
longer carries it.
Code that reads it the old way does not raise. It gets nil, writes nil into the mirror, and the
billing screen stops saying when the subscription renews. Two accounts on two API versions, running
the same deploy, behave differently.
Reading both and taking whichever answers is not elegant, and it is the only version that works
across an API version bump you do not control the timing of.
Where the money question sits
SubscriptionUpserted ends on a line that is not about subscriptions at all:
Referrals::Convert.new(user).callifrecord.live?
A referral pays out when the referred customer actually becomes live, not when they sign up. Putting
that in the mirror rather than in the checkout flow means it fires from whichever path made the
subscription live, including the webhook arriving for a customer who never came back to the site.
What this does not cover
Trials and what happens when one ends without a payment method. Failed renewals, past_due, and
Stripe's retry schedule, none of which this application handles yet: the webhook router listens for
checkout.session.completed and the three customer.subscription.* events, and not for
invoice.payment_failed. Writing that up before the code exists would describe a product that is
not this one.
For the screens a customer uses to do any of this themselves rather than through your UI,
the Stripe customer portal
is the shorter answer, and it is the one to reach for first.
Selling a subscription is one Checkout session. Rails Stripe Checkout covers that trip. Keeping one is three operations that a customer expects to be instant and reversible: switch plan, cancel, change their mind about cancelling.
Stripe subscriptions in Rails end up being less code than people expect, because those three are the same API call with different arguments. What takes the thought is what happens locally afterwards.
The shape all three share
Mutate at Stripe, then hand the returned object to the same class the webhook handler uses. Not a copy of it, not a similar one. The identical class.
That is the whole architecture, and it buys the thing that makes subscription code survivable: a few seconds later Stripe sends
customer.subscription.updateddescribing the change you just made, the webhook runsSubscriptionUpsertedon it, and the second pass writes exactly what the first pass wrote. No ordering problem, no "did the webhook get here yet", no reconciliation job.It works because the mirror is keyed rather than appended:
find_or_initialize_byon the Stripe id makes the write idempotent by construction. Replaying the same Stripe object a hundred times leaves one row in the state that object described. Stripe webhooks in Rails goes through why that endpoint receives duplicates as a matter of routine rather than as an edge case.The alternative, writing the local state from your own knowledge of what you just asked for, is where subscription bugs come from. Stripe may not have done what you asked. It may have done it and also done something else, like moving the subscription to
past_duebecause the proration charge failed. Reading the returned object is reading the answer instead of assuming it.The plan swap needs an item id
The extra
retrieveis not laziness. A subscription's line items have their own ids, anditems:is a write to that collection.Pass
{ price: new_price }without anidand Stripe reads it as a new item to add. The subscription now has two items, the customer is billed for the old plan and the new one together, and nothing errored. The invoice is where you find out.So the id has to come from somewhere, and fetching it just in time is the honest option: Stripe owns it, it can change, and caching it locally means a stale id the first time somebody edits the subscription in the Stripe dashboard.
Stripe subscription proration is on by default:
proration_behavior: "create_prorations"is what Stripe would do anyway, and it is written out because this is the line someone will want to change. An upgrade charges the difference for the remainder of the period; a downgrade credits it against the next invoice. The alternative,none, switches the plan and bills the new price from the next cycle, which is friendlier on a downgrade and a gift on an upgrade.Cancelling is a flag, not a deletion
The subscription stays
active. Stripe stops renewing it, and when the period closes it sendscustomer.subscription.deleted, which flips the mirror to canceled and takes access away then.Three properties come with that choice, and they are the reason to make it:
cancel_at_period_endtrue and acurrent_period_endin the future renders as "ends on the 14th" rather than as a binary.Cancelling immediately with
Stripe::Subscription.cancelis a different product decision: access stops now, the customer has paid for days they will not get, and you own the refund conversation.Resume is the same flag, backwards
Only while the subscription is still live. Once the period has closed and Stripe has actually cancelled it, there is nothing to resume: the object is terminal and the customer needs a new subscription, which means a new Checkout session and a new subscription id.
So a "reactivate" button has to know which of those two situations it is in, and the local mirror already carries the answer in
statusandcancel_at_period_end. Offering resume on a terminal subscription produces a Stripe error in front of somebody who was trying to give you money again.The field Stripe moved without telling anyone
An API version bump is the kind of change that does not announce itself.
current_period_endused to live on the subscription. On newer API versions it lives on the subscription item, and the subscription no longer carries it.Code that reads it the old way does not raise. It gets
nil, writesnilinto the mirror, and the billing screen stops saying when the subscription renews. Two accounts on two API versions, running the same deploy, behave differently.Reading both and taking whichever answers is not elegant, and it is the only version that works across an API version bump you do not control the timing of.
Where the money question sits
SubscriptionUpsertedends on a line that is not about subscriptions at all:A referral pays out when the referred customer actually becomes live, not when they sign up. Putting that in the mirror rather than in the checkout flow means it fires from whichever path made the subscription live, including the webhook arriving for a customer who never came back to the site.
What this does not cover
Trials and what happens when one ends without a payment method. Failed renewals,
past_due, and Stripe's retry schedule, none of which this application handles yet: the webhook router listens forcheckout.session.completedand the threecustomer.subscription.*events, and not forinvoice.payment_failed. Writing that up before the code exists would describe a product that is not this one.For the screens a customer uses to do any of this themselves rather than through your UI, the Stripe customer portal is the shorter answer, and it is the one to reach for first.