Answers · Billing

Why does a paying customer still see the paywall?

The local Subscription row for that account is not active. Entitlement never calls Stripe at request time, so a successful payment that produced no webhook leaves the row untouched. Check the row first, then Stripe's webhook delivery log. The third possibility is that the charge belongs to a different account than the one they are signed into.

Look at the row

bin/kamal app exec --interactive --reuse 'bin/rails console'
account = User.find_by(email_address: "[email protected]").account
account.subscription&.attributes

entitled? is true when the status is active or trialing. Anything else, including a missing row entirely, means the gate closes.

Then check whether the webhook arrived

Stripe's dashboard has a delivery log per endpoint showing every attempt and the response your app returned. Three outcomes and three different fixes:

  • No delivery at all. The event type is not enabled on the endpoint. See Stripe Setup.
  • Delivered, 400. Signature verification failed, so the handler never ran.
  • Delivered, 200, row still wrong. The handler ran against a different account than you expect.

The account mismatch

This is the one that looks impossible and is common. Billing belongs to an Account, not to a User, which is what lets a team share one subscription later. If someone paid while signed in with one email and is now signed in with another, they have two accounts, and only one of them is paying.

User.where(email_address: ["[email protected]", "[email protected]"]).map { |u| u.account_id }

Two different ids is your answer.

Fixing it by hand

Stripe is the source of truth for whether money moved. Once you have confirmed it did, activating the local row is safe and idempotent:

Subscription.activate!(account, plan: :pro)

That is the same call the webhook makes, so running it twice changes nothing.

Stop it happening again

If the webhook was the problem, fix the endpoint rather than the row. Stripe retries failed deliveries for three days, so a corrected signing secret usually replays the backlog on its own and the next customer never sees the paywall.

Related 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.