LaunchKit

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.

The shape all three share

updated = Stripe::Subscription.update(subscription.stripe_id, <the difference>)
Billing::Webhooks::SubscriptionUpserted.new(updated).call
updated

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:

record = user.subscriptions.find_or_initialize_by(stripe_id: subscription.id)
record.update!(stripe_price_id: price.id, status: subscription.status, ...)

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 plan swap needs an item id

def call
  updated = Stripe::Subscription.update(
    subscription.stripe_id,
    items: [ { id: current_item_id, price: price_id } ],
    proration_behavior: "create_prorations"
  )
  Billing::Webhooks::SubscriptionUpserted.new(updated).call
  updated
end

def current_item_id
  Stripe::Subscription.retrieve(subscription.stripe_id).items.data.first.id
end

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.

Cancelling is a flag, not a deletion

Stripe::Subscription.update(subscription.stripe_id, cancel_at_period_end: true)

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.

Resume is the same flag, backwards

Stripe::Subscription.update(subscription.stripe_id, cancel_at_period_end: false)

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.
def current_period_end
  item.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).call if record.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.

More on Stripe billing in Rails

← All Stripe billing in Rails articles