Skip to content

Free trials in Stripe that do not leak access

trialing is an entitled status and trial_will_end is not a payment. Where trials are configured, which events actually matter, and how to stop one person taking ten trials.

Stripe5 min readships at docs/solutions/stripe/free-trials-that-do-not-leak.md

Tags: stripe · trials · subscriptions · entitlements · webhooks · billing

Trials look like a small feature and produce a disproportionate number of billing bugs, because they introduce a subscription state where the customer has full access and has paid nothing.

The three failures, in the order teams hit them:

  1. Access is gated on status === "active", so trialing customers are locked out of the product they just signed up for.
  2. Access is gated on "a subscription row exists", so a customer whose trial ended without a payment method keeps everything, forever.
  3. One person creates ten accounts and takes ten trials.

Where a trial is configured

Three places, in increasing order of preference:

On the Checkout Session, as subscription_data.trial_period_days. Checkout takes the trial from the session, so this is the setting that decides it. In this project the length lives next to the price it belongs to, in the catalogue (src/lib/pricing.ts):

{ id: "pro-monthly", interval: "month", amount: 1900, trialDays: 7 },

and the Stripe adapter's createCheckout passes it on:

subscription_data: {
  billing_mode: { type: "flexible" },
  metadata: input.metadata,
  ...(input.price.trialDays ? { trial_period_days: input.price.trialDays } : {}),
},

The pricing card reads the same field ("Start 7-day free trial"), so the page and the checkout cannot disagree.

Never as a stray constant. const TRIAL_DAYS = 14 in some config file is the same class of mistake as a hardcoded price: it drifts from what the pricing page promises and from what Stripe actually did. One field, on the price, read by both.

Two useful options on subscription_data:

  • trial_settings.end_behavior.missing_payment_method: cancel, pause or create_invoice. This is what happens when the trial ends and there is no card on file. Choose deliberately.
  • payment_method_collection: "if_required" on the Checkout Session lets someone start a trial without entering a card at all. Higher conversion into the trial, much lower conversion out of it. Know which you are optimising.

The entitlement check

trialing is an entitled status. So is active. So, usually, is past_due (for a grace period), and that is a product decision, not a technical one. This project grants access on all three.

export const ENTITLED_SUBSCRIPTION_STATUSES = ["trialing", "active", "past_due"] as const;

export function isEntitled(subscription: Pick<BillingSubscription, "status"> | null): boolean {
  return subscription !== null && isEntitledStatus(subscription.status);
}

Write it once, in one module, and gate everything on that function. The bug this prevents is not subtle: six components each doing subscription?.status === "active" means six places that lock trialing customers out, and you will find five of them.

The full status set is worth knowing, because collapsing it early loses information you need:

StatusAccessMeaning
trialingyesIn trial, no payment taken yet
activeyesPaid and current
past_dueyour callA payment failed; Stripe is retrying
unpaidnoRetries exhausted
cancelednoOver
incompletenoFirst payment never completed (3DS abandoned)
incomplete_expiredno…and the window closed
pausednoTrial ended with no payment method, end_behavior: pause

Store the status, not a boolean. Deriving hasAccess at write time throws away the difference between "retrying a payment" and "gone", and those need different UI. This project stores a normalized status (the same words for every payments provider, incomplete_expired becomes expired) and Stripe's own word in provider_status.

The events that matter

  • customer.subscription.created with status: trialing: the trial began.
  • customer.subscription.updated: the workhorse. The trial→active transition, the trial→canceled transition, and every plan change all arrive as this. Re-read the subscription and upsert; do not try to infer what changed.
  • customer.subscription.trial_will_end fires about 3 days before the trial ends. This is a marketing signal, not a billing one. Send the "your trial ends Friday" email here. Do not change entitlements on it: the trial has not ended, and acting on it early is how you cut someone off three days early.
  • invoice.payment_failed right after a trial: the card on file was declined at conversion. The subscription goes past_due, and this is the single highest-value dunning email you will ever send.

The handler for all of these is the same shape, and that is the point:

case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
  return subscriptionEvents(event.data.object.id); // re-read, then map

Re-fetch and upsert. Out-of-order delivery (routine around trial conversion, because several objects change within the same second) stops mattering.

trial_will_end is not in this project's handled events. Add it to HANDLED_EVENTS in src/lib/billing/provider.ts and to the dashboard endpoint when you want the reminder email.

Cancelling during a trial

A customer who cancels at period end in the portal mid-trial keeps access until the trial ends and is never charged. On a flexible billing mode subscription (every one this repo's checkout creates) Stripe records that as cancel_at and leaves trial_end where it was, so the row still says trialing with a scheduled end. /billing reads both through scheduledEnd() and says the trial ends without renewing, instead of promising a conversion that will not happen.

Do not compute trial state yourself

// don't
const inTrial = subscription.trialEnd && subscription.trialEnd > new Date();

Server clocks drift, trials get extended from the dashboard, and Stripe's own status already answers the question. Keep trial_end in the projection for display ("7 days left") and gate access on status.

Stopping trial farming

Stripe does not do this for you. The options, roughly in order of effectiveness per unit of annoyance:

  • Require a card to start the trial. Filters most casual abuse and improves conversion, at the cost of top-of-funnel.
  • Record which of your own users has trialled. A boolean on the user, or a query over billing_subscriptions for any prior row with a trial_end, checked before the trial is attached (in this project, createCheckout in src/lib/billing/provider.ts). Cheap and effective for same-account retries.
  • Block disposable email domains at signup.
  • Fingerprint the payment method. Stripe exposes a fingerprint on card payment methods that is stable across customers for the same physical card. Refusing a second trial on the same fingerprint stops the determined case. Be careful: shared corporate cards exist, so make it a flag for review rather than a hard block if your customers are businesses.

Whatever you pick, enforce it server-side where the Checkout Session is created. A check in the UI is a suggestion.

What to build first

Get isEntitled right, store the status verbatim, handle customer.subscription.updated by re-fetching, and send an email on trial_will_end. That is the whole trial feature for most products. Trial farming is a problem you should solve when you have evidence of it, not before: the mitigations all cost conversion.