App Paywall
Customize the built-in paywall, or run your app without one.
Once Stripe or RevenueCat is syncing, every active price lands on the built-in paywall without further work. This page is about the parts you do have to decide: the copy on the cards, the two recovery surfaces around them, one-time "lifetime" plans, and how to switch the whole thing off.
Access itself is gated by hasActiveSubscription in
lib/core/presentation/utils/access_gate.dart; see Apps without a
paywall to remove the gate entirely.
Requires ENABLE_REVENUECAT_PAYWALL
RevenueCat-hosted paywall is off in a new project. Set `ENABLE_REVENUECAT_PAYWALL=true` in `.env.local` to use this section.
RevenueCat's paywall replaces this page's advice
When the app renders RevenueCat's hosted paywall, none of the customization
below applies: descriptions, feature lists, the trial badge and the
payment-recovery banner all live in AppPaywall and are simply not built.
See Which paywall renders for when
that swap happens.
Plan descriptions
Descriptions come from your payment provider by default. To override them, for
localization, or just for better copy, edit the descriptions map already
passed to PriceListView in AppPaywall.
Keys are plan names, matched exactly (plan.name, case sensitive). A plan
with no entry falls back to the provider's own description.
PriceListView(
descriptions: const <String, String>{
'Basic': 'Perfect for getting started.',
'Premium': 'Best for growing startups and growth companies.',
},
// ...
)Plan features
Same widget, same keys: features maps a plan name to the bullet list rendered
under its price.
PriceListView(
features: const <String, List<FeatureCard>>{
'Basic': [
FeatureCard(
icon: LucideIcons.timer,
description: 'Access to basic features',
),
FeatureCard(
icon: LucideIcons.lock,
description: 'Secure your account',
),
],
'Premium': [
FeatureCard(
icon: LucideIcons.infinity,
description: 'Unlimited access to all features',
),
FeatureCard(
icon: LucideIcons.zap,
description: 'Premium performance boost',
),
],
},
// ...
)FeatureCard takes a required description and an optional icon
(lib/features/payments/presentation/views/widgets/price_card.dart). Your
project ships with example maps for Basic and Premium already filled in;
replace them with your own plan names rather than adding a second map.
Both maps are constructor arguments, not global configuration. The billing page
mounts its own bare PriceListView()
(lib/features/payments/presentation/views/widgets/billing_content.dart), so
pass your maps there too if you want the same copy when a subscriber changes
plan.
The other knobs are provider-side
Two things on the card are not set in Dart. The Popular badge follows
plans.popular in your database, and a card only appears at all while its
price row has active = true.
The free-trial badge
A price whose trial_days is greater than zero gets a Free trial 🎉 badge
pinned to the right of the card title, and its call to action changes from
"Get started with …" to "Start 7 days free trial".
The badge is deliberately quiet. PriceCard suppresses it unless every one of
these holds:
- The user holds no manageable subscription. Anyone already subscribed sees plan-switching copy instead.
- The user has never trialled this specific price before.
- The user is not globally trial-ineligible (
trialIneligibleSinceis unset). trial_days > 0on the price.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
The "never trialled this price" check reads user.allTrials, which merges the
user's own trials with those of their current organization
(lib/core/domain/models/user.dart). A trial the organization already
burned therefore hides the badge from every member.
Everything behind the badge (how trial_days gets set, eligibility, the
trial-ending notifications, and how to let someone trial twice) is on the
Free Trials page.
The payment recovery banner
When a card is declined, Stripe does not end the subscription: it moves it to
past_due (then unpaid) and keeps retrying. Subscription.isActive() accepts
only active and a live trialing, so from the app's point of view the user is
instantly gated, and a paywall that offers nothing but "subscribe again" is a
dead end for someone who already pays you.
PaymentRecoveryBanner
(lib/features/payments/presentation/views/widgets/payment_recovery_banner.dart)
is the way out. It renders above the price cards, so it is read first.
It appears when needsPaymentMethodRecovery is true, which means both of:
- the subscription's status is
past_dueorunpaid, and - the subscription came from Stripe.
RevenueCat subscriptions are excluded on purpose: their payment method lives in the App Store or Play Store account, not in any portal this app can open.
Its button, UpdatePaymentMethodButton, opens the Stripe billing portal
straight onto its add-a-card screen (PaymentActionType.paymentMethodUpdate)
and returns the user to the page they were on.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
Members see the warning, not the button
The button is gated by canRecoverPaymentMethod, which mirrors the
server-side billing permission check. A member without billing.manage gets
the banner with different copy ("Ask an owner or an admin to update it")
and no button, because pressing one would only produce a permission-denied
toast.
Roles and billing.manage are explained on Organizations.
Restore purchases
Store purchases can outlive the account row that recorded them, so Restore
purchases reconciles the store receipt against the account. It appears on
the built-in paywall, in Account settings, and on RevenueCat's own paywall,
wherever context.canRestorePurchases is true. See Restore
purchases on the RevenueCat page for the full
explanation, including what each outcome means.
Lifetime subscriptions
A "lifetime" plan is a one-time payment wearing a subscription's clothes: the
app grants access by reading public.subscriptions, so a one-time purchase has
to leave a row there too.
Create a one-time price in Stripe or RevenueCat, following the provider
guide. It syncs into your prices table with interval set to NULL, which
keeps it off the paywall.
Mark it as lifetime. The price_interval enum already includes a lifetime
value:
UPDATE prices
SET interval = 'lifetime'
WHERE id = '<lifetime_price_id>';The paywall groups prices by interval and opens on monthly, so the lifetime price appears as its own segment in the interval toggle rather than beside your recurring plans.
Grant access when it is paid. Handle the one-time checkout in
supabase/functions/stripe-webhook/_events/checkout_events.ts (or the
RevenueCat equivalent) and insert the synthetic subscription row the entitlement
check looks for. The file carries // ! ... comments marking the exact spot.
// The session carries no price of its own; it is on the line items.
const lineItems = await getStripe().checkout.sessions.listLineItems(
session.id,
{ limit: 1 },
);
const priceId = lineItems.data[0]?.price?.id;
if (!priceId) throw new Error(`No price on session ${session.id}`);
const subscription: TablesInsert<"subscriptions"> = {
// The session id keeps webhook retries idempotent.
id: session.id,
user_id: user.id,
price_id: priceId,
status: "active",
current_period_start: new Date().toISOString(),
// Far enough out that it never expires in practice.
current_period_end: new Date(
new Date().setFullYear(new Date().getFullYear() + 100),
).toISOString(),
cancel_at_period_end: false,
};
const { error } = await supabase.from("subscriptions").insert(subscription);
if (error) {
console.error(`DB: Error creating subscription ${subscription.id}:`, error.message);
throw error;
}That needs two imports the file does not have yet: getStripe from
../../_shared/clients.ts (the pattern its sibling invoice_events.ts already
uses) and TablesInsert from ../../_shared/database.types.ts.
Use the session id, not a fresh UUID
Stripe retries webhook deliveries. Keying the row on session.id makes a
replay a no-op instead of a second free lifetime grant.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
For an organization-owned purchase, set organization_id (and
purchased_by_user_id) on the row instead of leaving it as a personal
subscription.
The 100-year current_period_end is what makes it work: app.has_entitlement
requires status IN ('active','trialing') and current_period_end > now(),
and Subscription.isActive() on the client requires the same window. On the
paywall itself, a lifetime plan the user already owns shows "Current plan" with
no action; there is nothing to manage.
Apps without a paywall
If your app is free, or monetized somewhere other than a subscription, edit
one function: hasActiveSubscription in
lib/core/presentation/utils/access_gate.dart.
/// Whether [user] holds a subscription that is currently active.
///
/// This app is free, so nobody is ever gated behind the paywall.
bool hasActiveSubscription(User? user) {
return true;
}That single edit propagates everywhere the gate is consulted:
_inactiveSubscriptionRedirectreturnsnullfor every route, so no navigation is bounced to/paywall.defaultPostAuthPathsends users to the home page after sign-in.isGatedbecomes false, so the nav bar, account settings and keyboard shortcuts stop hiding the app shell from unsubscribed users.
Changing the post-auth destination is not enough
It is tempting to point the router's post-auth path at your dashboard and stop
there. It does not work: defaultPostAuthPath only chooses the landing
route, while _inactiveSubscriptionRedirect re-evaluates on every navigation
and immediately bounces the user back to /paywall. Widgets read isGated
independently, so a router-only change would also leave the navigation hidden.
Both read hasActiveSubscription, which is why that is the one place to
change.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
Multi-tenancy has a second, independent gate: requiresOrganizationAccessHub
still routes a user with no current organization to the access hub. That is
organization selection, not billing, and it stays; leave it alone unless you
are also removing organizations. The hub itself is covered on
Organizations.
Two loose ends worth tidying:
test/core/presentation/utils/access_gate_test.dartasserts the old behaviour. Update itshasActiveSubscriptiongroup./paywallstill resolves if someone types it. Drop the route fromPaymentsRouter.routes(lib/features/payments/presentation/router/payments_router.dart) if you want it gone entirely.