LaunchKit

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.

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.created and customer.subscription.updated describe 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.

Stripe can deliver customer.subscription.updated first and customer.subscription.created second, and both handler designs receive that same pair in that order. The handler that reads events as instructions, create on created and update on updated, runs the create last and writes the older payload over the newer one, so the customer sits on a plan they have already left. The handler that reads each event as a snapshot runs one upsert for both event types, so the write is decided by what the payload currently says rather than by which event name arrived.

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, canceled and incomplete on 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_end off the subscription and onto the subscription item. Code that reads it from the old place returns nil against 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.

Articles on this topic