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.
Why the app side is already done
def entitled? = active? || trialing?
That is the whole of it, in app/models/subscription.rb. The status enum already has trialing, so
a trial is not a special case anywhere in the gate, and app/models/entitlement.rb asks the same
question whether or not a trial is involved.
What Stripe needs
Two settings, both on Stripe's side:
- A trial period on the price or passed into the checkout session.
- Payment method collection set so a card is not required to start.
Without a card, Stripe cannot charge when the trial ends. It sends
customer.subscription.updated with the status moving out of trialing, and the subscription is
canceled rather than billed. Your gate closes, which is the behavior you want.
The event that matters most
customer.subscription.updated is what carries trial transitions, and it is easy to leave disabled
because it sounds generic. If it is not enabled on the endpoint, a trial that ended looks like an
active trial forever, and you give the product away indefinitely.
bin/kamal app exec --interactive --reuse 'bin/rails console'
Subscription.where(status: "trialing").where("updated_at < ?", 30.days.ago).count
A number above zero on a 14 day trial is that bug.
Card or no card
No card converts more people into trials and fewer trials into customers, because the end of the trial is a decision rather than a default. With a card it is the reverse. Neither is wrong, but pick deliberately and then measure it, rather than inheriting whichever one you configured first.
Test the whole arc
Stripe test mode lets you advance a subscription's clock, which is the only honest way to test the end of a trial. Without it you are asserting that a status change you never saw will do what you expect. Run it once, watch the webhook arrive, and confirm the gate closes.