🚀 Join the waitlist now! waitlist.floot.dev
LogoFlootdocs
Payments

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:

CheckWhy it fails
The price is recurring and trial_days > 0Nothing is on offer
No row in public.trials for this user and priceThey already used this trial
users.trial_ineligible_since is NULLFlagged 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:

SuppressorMeaning
The user already has a subscription they manageTrials are for new subscribers; the card offers a plan switch instead
A trials row already matches this priceThis trial has been used
trialIneligibleSince is setPermanently 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 lengthNotice sent
Under 3 daysNone
Exactly 3 days1 day before
4 to 6 days2 days before
7 days or more3 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:

SituationBody
Trial still convertingThe trial ends on date, the subscription begins and the payment method on file is charged
Trial already canceledThe 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.

On this page