Free Trials
How a trial is offered, who is eligible for one, and what the backend does while it runs.
A free trial in your project is one number and one table. The number is
prices.trial_days; the table is public.trials, which records that a trial
was granted so it is never granted twice.
Everything on this page ships in the base project. It is the same machinery whether the money comes from Stripe or RevenueCat; the two providers differ only in where you type the trial length.
Subscriptions only
Trials only exist on recurring prices. The database enforces it: trial_days
carries a CHECK that a one-time price must leave it at 0, so a stray
update on a lifetime price is rejected rather than quietly ignored.
Where the number comes from
public.prices has a trial_days SMALLINT NOT NULL DEFAULT 0 column. Zero
means no trial, and zero is the default, so a new project offers no trials
until you set one.
Stripe writes it for you. Put a trial_days key in the price's
metadata, and the price.created / price.updated webhook parses it into the
column. See Free trials on the Stripe page; the
dashboard is the recommended route, because it keeps Stripe as the source of
truth for what you are currently offering.
RevenueCat cannot: an introductory offer lives in App Store Connect and Google Play, and RevenueCat does not surface its length in a form the webhook can map onto a price. You set the column yourself so the paywall can show the offer. See Free Trials.
Either way, trial_days is what the paywall reads and what the checkout
call sends to Stripe as trial_period_days. If it is 0, no badge, no trial
button, no trial.
Who gets one
By default, a user gets one trial per price, once. Three separate things enforce that, and they run at different moments.
At checkout: the decision
The checkout session backend only attaches trial settings when all of the following hold:
| Check | Why it fails |
|---|---|
The price is recurring and trial_days > 0 | Nothing is on offer |
No row in public.trials for this user and price | They already used this trial |
users.trial_ineligible_since is NULL | Flagged as a returning deleted account |
When a trial is granted the session carries trial_period_days plus
trial_settings.end_behavior.missing_payment_method: "cancel", so a trial
that ends with no usable card lapses instead of failing payment forever.
Deleting the account does not reset the trial
Signing up again with the email of a previously deleted account sets
users.trial_ineligible_since permanently, which closes the "delete, re-sign
up, trial again" loophole. The column is written by an internal trigger; a
client's own UPDATE on it is rejected outright with You are not allowed to update your trial eligibility.
After the fact: the record
public.trials is written by manage_trial(), an AFTER INSERT OR UPDATE
trigger on public.subscriptions. When a subscription row arrives with
status = 'trialing' and both trial_start and trial_end set, it inserts:
INSERT INTO public.trials (user_id, price_id, start_date, end_date)
VALUES (NEW.user_id, NEW.price_id, NEW.trial_start, NEW.trial_end);The table's primary key is (user_id, price_id), and the function only ever
inserts; it never updates or deletes. That is deliberate: the row is the
permanent proof that this user has had this trial, so the history survives
cancellations, re-subscriptions and plan changes.
It is a backstop, not the gate. If a trialing subscription somehow shows up for
a flagged user, manage_trial() records it anyway and raises a WARNING,
because the provider has already committed to the trial and the database has to
reflect reality. The place to fix a bypass is the checkout check above.
Reading it back
public.trials is readable by authenticated under RLS, restricted to the
caller's own rows, which is how the app knows to hide the badge. Writes are
service_role only.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
Requires ENABLE_MULTI_TENANCY
Multi-tenancy is off in a new project. Set `ENABLE_MULTI_TENANCY=true` in both `.env.local` and `supabase/.env.local` to use this section.
Trials also carry an organization_id, and eligibility is checked against
both: a trial is denied if this user or this organization has already
trialed the price. Otherwise every member of an org could take the same trial in
turn, and spinning up organizations would mint unlimited fresh ones.
Organizations themselves, including how the current one is chosen, are on
Organizations.
The paywall badge
PriceCard (lib/features/payments/presentation/views/widgets/price_card.dart)
shows a Free trial 🎉 badge in the card header, and swaps its CTA from
Get started with… to Start N day free trial.
It appears only when trial_days > 0 and none of these three suppressors
applies:
| Suppressor | Meaning |
|---|---|
| The user already has a subscription they manage | Trials are for new subscribers; the card offers a plan switch instead |
A trials row already matches this price | This trial has been used |
trialIneligibleSince is set | Permanently flagged |
Those are the same three conditions the checkout function evaluates server-side, mirrored into the UI so the paywall never advertises a trial that checkout will then refuse. The client copy is a courtesy; the server is the authority.
The rest of the paywall (plan descriptions, feature lists, lifetime prices) is covered on the Paywall page.
Trial-ending notices
Shortly before a trial ends, the buyer gets an email. Nothing about this is
provider-specific: the sweep reads public.subscriptions, which both the Stripe
webhook and the RevenueCat webhook write to.
A pg_cron job named trial-ending-notify-sweep runs on '23 */3 * * *',
every three hours, at 23 minutes past. It calls
app.dispatch_edge_webhook('notify-trial-ending', …), which is a signed
pg_net request. An unsigned call to the function is refused with a 401.
The notify-trial-ending Edge Function fetches up to 200 subscriptions with
status = 'trialing' whose trial_end falls in the next three days, earliest
first, and works out how much notice each one has earned.
For each one that is due, it claims a public.notifications row, sends the
email, and moves on. A send failure deletes the claim so the next sweep retries.
The sweep runs locally too
On a developer machine it will happily email the test inboxes of any trialing subscription you seeded while testing, every three hours, from your laptop. The migration says as much where it schedules the job. To stop it in a local database:
SELECT cron.unschedule('trial-ending-notify-sweep');Only do this locally. Unscheduling it in production silently turns off every trial-ending email.
The lead-day ladder
How much notice a trial earns depends on how long it was:
| Trial length | Notice sent |
|---|---|
| Under 3 days | None |
| Exactly 3 days | 1 day before |
| 4 to 6 days | 2 days before |
| 7 days or more | 3 days before |
Two consequences worth knowing:
- The three-hour cadence is what makes the ladder work. The shortest window is 24 hours, so every window still gets several passes, and a failed send is retried within three hours. If you change the ladder, keep the cron interval well under the shortest window.
- The sweep's own lookahead is three days, matching the top rung. Raising a rung above three days without raising that horizon would mean the row is never even fetched.
The email
The template is TrialEndingEmail, in
supabase/functions/send-email/_templates/subscriptions/trial-ending-email.tsx.
It renders in the recipient's language (from users_prefs.language, English or
French), with the subject from
supabase/functions/send-email/_templates/l10n.ts: "Your free trial is
ending soon".
It has two bodies, chosen from the subscription itself:
| Situation | Body |
|---|---|
| Trial still converting | The trial ends on date, the subscription begins and the payment method on file is charged |
| Trial already canceled | The trial ends on date and access ends with it; resubscribe to keep it |
"Canceled" means canceled_at is set or cancel_at_period_end is true;
both are checked, because a RevenueCat trial that has been canceled keeps
status = 'trialing' until it expires, and some cancel reasons leave
cancel_at_period_end false.
The CTA points at /billing on your WEBSITE_URL, and delivery uses the same
SMTP configuration as the rest of the project's transactional email, so
SMTP_HOST and SMTP_FROM must be set for notices to go out.
Allowing more than one trial per user
Since eligibility is nothing more than "is there a row in public.trials",
letting someone trial again is a matter of deleting theirs. The table is
public.trials; writes are service_role, so this belongs in the SQL editor
or a scheduled job, not in client code.
To clear one specific user's trial on one price:
DELETE FROM public.trials
WHERE user_id = '<user_uuid>' AND price_id = '<price_id>';To reopen trials generally, for example letting anyone who trialed more than a year ago try again:
DELETE FROM public.trials
WHERE end_date < now() - INTERVAL '1 year';Run that on a schedule and trials become renewable rather than once-ever. pg_cron
is already installed, so it can live next to the sweeps:
SELECT cron.schedule(
'trials-expire-old',
'0 4 * * *',
$cron$ DELETE FROM public.trials WHERE end_date < now() - INTERVAL '1 year' $cron$
);Two gates, not one
Deleting the row clears the per-price check only. A user flagged with
users.trial_ineligible_since stays ineligible on every price until that
column is cleared, which is the point of the flag, so clear it only when you
have decided a particular account is not an abuse case.
Deleting a trial does not end a trial
The rows in public.trials are a history of grants, not the live state of
anything. A trial in progress lives on public.subscriptions
(status = 'trialing', trial_start, trial_end) and at the provider.
Removing a trials row makes a future checkout eligible again; it does not
extend, shorten or cancel a trial that is currently running.