Stripe billing in Rails
Checkout, subscriptions, coupons and webhooks. The API calls are the easy half; what takes the time is staying correct when Stripe retries, reorders and contradicts itself.
Articles on this topic
-
Stripe subscriptions in Rails: change, cancel, resume, and one mirror
Three mutations that are all the same Stripe call, each followed by the webhook's own code. Why the plan swap needs an item id, why cancelling is a flag rather than a deletion, and the field Stripe moved without telling you.
-
Stripe coupons and promotion codes are two objects, and Checkout takes one or the other
A coupon is the discount. A promotion code is the string a customer types to reach it. Checkout accepts either but never both, which is why the discount option in a Rails integration is a ternary rather than two merged hashes.
-
The Stripe customer portal in Rails: one session, no billing UI
Stripe hosts the screens where a customer changes their card, switches plan or cancels. The Rails side is one API call and a redirect. What it needs that Checkout may not have given you, and why nothing about the return can be trusted.
-
Rails Stripe Checkout: creating the session and reading the return
One API call out, one server fetch back, and nothing in between is trusted. The flags that decide whether a Stripe customer exists afterwards, and why the success page reads the session from Stripe rather than from the URL.
-
Stripe webhooks in Rails, and the idempotency the database owes you
Verifying the signature against the raw body, and making a handler safe to run twice. The guarantee belongs to a unique index, not to a model validation, and the savepoint is what lets Stripe retry.
A Stripe integration reads like a morning's work. Create a Checkout session, redirect, take the webhook, grant access. Every one of those is a documented API call, and none of them is where the time goes.
The time goes into a single structural fact: billing is two systems holding the same truth. Stripe owns the money, the cards, the invoices and the retries. Your database owns what this customer is allowed to do right now. Those two records have to agree, and they are updated by different machines, at different times, over an unreliable network.
What Stripe does for you, and what it hands back
Stripe Checkout is genuinely the part you do not build. It hosts the page, renders the card fields in its own iframe, handles 3D Secure, and keeps card data off your servers entirely, which is what keeps your PCI scope small. You create a session, you redirect, and the customer comes back.
What comes back is the problem. The redirect is a browser, and a browser can be closed, refreshed, or never followed at all. So the redirect is never the source of truth about a payment. The truth arrives separately, asynchronously, as a webhook, from Stripe's servers to yours, with its own retry schedule and no guarantee of order.
Creating the session and reading the return is the half of the work either side of that redirect: the one create call, the flags that decide whether a Stripe customer exists afterwards, and why the success page fetches the session back from Stripe rather than believing the id in the URL.
That asymmetry is the whole design constraint. Everything below follows from it.
The webhook is the only thing that may grant access
If the success page grants access, a customer who closes the tab before it loads has paid and received nothing, and a customer who guesses the URL receives something without paying. Neither is recoverable by being careful in the controller.
So the success page reads state and the webhook writes it. That split is not a stylistic preference; it is the only arrangement in which a closed tab and a duplicated request both end correctly.
Stripe then retries the webhook until it receives a 2xx, which means the same event reaches your handler more than once as a matter of routine rather than as an edge case. Stripe webhooks in Rails is the page that works through the consequences: verifying the signature against the raw body, and making each handler idempotent on a guarantee the database enforces rather than one the code hopes for.
Stripe subscriptions in Rails arrive out of order
The second surprise is that Stripe does not promise sequence.
customer.subscription.createdandcustomer.subscription.updateddescribe the same subscription at two moments, and the second can reach you before the first.A handler written as "create on created, update on updated" therefore writes the older state over the newer one, and the customer ends up on a plan they have already left. The fix is to stop treating the events as instructions and start treating each one as a snapshot: both event types run the same upsert, which writes whatever the payload currently says.
What a subscription costs you to keep in step
A subscription is not a boolean. It has a status that Stripe moves through
trialing,active,past_due,canceledandincompleteon its own schedule, a period end that shifts every renewal, a cancel-at-period-end flag that means "still active, but not next month", and a trial end that is neither of those.Every one of those has to be mirrored locally, because the page that decides whether to show a feature cannot make an API call to Stripe on every request. Getting the mirror right is most of the work in a rails stripe integration, and it is the part that has no interesting API surface to show in a tutorial.
The field that moved
A detail worth knowing before it costs you an afternoon: recent Stripe API versions moved
current_period_endoff the subscription and onto the subscription item. Code that reads it from the old place returnsnilagainst a newer API version, silently, and your local mirror quietly stops knowing when anything renews.Reading whichever shape the account's API version returns is cheap. Discovering the move from a customer complaint is not.
After the sale, Stripe hosts the screens
The card that expires, the plan that changes, the subscription that gets cancelled: none of it is worth building, and Stripe hosts all of it. The Stripe customer portal in Rails is one API call and a redirect, with two consequences that matter more than the code: it needs a Stripe customer that a payment-mode Checkout may never have created, and the session it mints cannot be fetched back, so what the customer did reaches you as a webhook or not at all.
Discounts are the one Checkout argument with a decision hidden in it. Stripe coupons and promotion codes separates the two objects, explains why a session takes one or the other and never both, and covers the deterministic coupon id that keeps a referral discount from filling the dashboard with thousands of identical coupons.
Keeping a subscription is three operations
Changing plan, cancelling and changing your mind about cancelling are the same Stripe call with different arguments. Stripe subscriptions in Rails works through the item id a plan swap needs and what an invoice looks like without it, why cancelling is a reversible flag rather than a deletion, and why every local write goes through the same class the webhook uses so the two can never fight.