Docs · Billing & Entitlements

How Billing Works

The Local fake versus real Stripe, and what flips a build over to live billing.

Billing follows the same adapter-plus-fake seam every other integration in the kit uses. The presence of one environment variable, STRIPE_SECRET_KEY, is what flips the whole app from a Local billing fake to real Stripe. Nothing in your controllers or views needs to know which one is active; they all talk to Billing.for(Current.account).

The Local fake

Without a Stripe key, Billing.for(account) returns a fake that behaves like Stripe closely enough to build and test against: it tracks a subscription state, honors entitlement checks, and lets you exercise the full paid-feature flow without a Stripe account, a webhook, or a network call. This is why a fresh clone of the kit runs its entire billing surface, including the pricing page and the entitlement gate, before you've configured anything. See The Adapter+Fake Pattern for the general mechanism this is built on.

Turning on real Stripe

Set STRIPE_SECRET_KEY (start with a test key, sk_test_...) and the app starts talking to Stripe for real. You'll also need:

  • STRIPE_WEBHOOK_SECRET, from your webhook endpoint at /webhooks/stripe. Locally, run stripe listen --forward-to localhost:3000/webhooks/stripe to get a matching secret.
  • A price id for each plan you offer, for example STRIPE_PRICE_PRO, created in Stripe or through the Stripe MCP server (see .mcp.json).

See Stripe Setup for the full checklist, including the statement descriptor you should set before charging anyone.

How a controller checks entitlement

Billing.for(Current.account).entitled? is the one question every paid feature asks. You rarely call it directly: RequireEntitlement, included in a controller, calls it on every action and redirects to pricing when it's false. See Gating a Feature for how to wire that into a new resource, and Authorization for how entitlement and Pundit's record-level checks fit together as two separate layers.

Webhooks

Stripe delivers subscription lifecycle events (created, updated, canceled, payment failed) to /webhooks/stripe, which verifies the signature before acting on anything. This is what keeps entitled? accurate without polling Stripe on every request: the account's local billing state updates the moment Stripe's state changes, not the next time someone happens to check.

Next

Add the entitlement gate to a resource of your own: Gating a Feature.

Common questions

Can I charge a one-time price instead of a subscription?

Yes. Create a one-time price in Stripe, use payment mode rather than subscription mode in the checkout session, and change what Entitlement.for returns so a completed purchase entitles the account permanently. The gate on each controller does not change, because RequireEntitlement never knew what it was checking.

How do I let customers cancel their own subscription?

Send them to the Stripe billing portal rather than building a cancellation UI. One Shot ships the route already: a form that posts to the billing portal controller and redirects to Stripe. When they cancel there, customer.subscription.deleted arrives and the local row is updated, so access ends without you writing any of it.

How do I offer a free trial without taking a card upfront?

Set a trial period on the Stripe price or the checkout session and allow it without a payment method. The app needs no change: Subscription#entitled? returns true for both active and trialing, so a trialing account has full access, and customer.subscription.updated moves it when the trial ends.

How do I see which accounts are actually paying me?

Query the Subscription table in the Rails console: active and trialing rows are the entitled accounts. One Shot ships no admin UI on purpose. For anything about money rather than access, read Stripe's dashboard instead, because the local row tracks entitlement and Stripe tracks what was actually charged.

How do I test a checkout flow without a Stripe account?

Do nothing. With STRIPE_SECRET_KEY unset, Billing.for(account) returns a local fake that runs checkout, entitlement and cancellation in process. You click through the real flow, the subscription row is written, and gated features unlock. Set the key when you want real Stripe, and the same code paths switch over.

What happens when a paying customer's card expires?

Stripe retries the charge on a schedule and emails the customer. Your app receives invoice.payment_failed, which moves the subscription to past_due rather than canceling it. Access continues, because past_due is a recoverable state and most of these recover. Only customer.subscription.deleted ends entitlement.