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.

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.

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.

Articles on this topic