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

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.

lib/features/payments/presentation/views/widgets/app_paywall.dart
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.

lib/features/payments/presentation/views/widgets/app_paywall.dart
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 (trialIneligibleSince is unset).
  • trial_days > 0 on 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_due or unpaid, 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.

supabase/functions/stripe-webhook/_events/checkout_events.ts
// 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.

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:

  • _inactiveSubscriptionRedirect returns null for every route, so no navigation is bounced to /paywall.
  • defaultPostAuthPath sends users to the home page after sign-in.
  • isGated becomes 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.dart asserts the old behaviour. Update its hasActiveSubscription group.
  • /paywall still resolves if someone types it. Drop the route from PaymentsRouter.routes (lib/features/payments/presentation/router/payments_router.dart) if you want it gone entirely.

On this page