# How to Add Subscription Management to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Subscription management needs six components: a pricing page, Stripe Billing for subscription lifecycle, a webhook handler that processes subscription events, a subscriptions table in Supabase, a plan access middleware that gates features, and a Stripe Customer Portal for self-serve billing. With Lovable or V0 you ship a working SaaS billing flow in 4–8 hours. Running costs are $5–100/mo depending on MRR — Stripe Billing charges 0.5% of recurring revenue on top of the per-charge fee.

## What Subscription Management Actually Is

Subscription management is the billing infrastructure behind any product that charges users on a recurring schedule — monthly SaaS plans, annual memberships, mobile app subscriptions. It is not just the payment form: it is the entire lifecycle from plan selection to cancellation, including upgrade proration, dunning when cards fail, self-serve billing changes, and the database state that gates what each user can access. Stripe Billing handles the financial mechanics — subscription creation, invoice generation, retry logic. Your job is to build the pricing page, wire the webhook events that keep your database in sync, enforce access based on subscription status, and give users a self-serve way to manage their plan without contacting support. Get these four things right and subscription management runs on autopilot.

## Anatomy of the Feature

Six components. The webhook handler and plan access middleware are where most SaaS billing breaks under real usage — AI tools routinely omit server-side access checks and idempotent event handling.

- **Pricing page** (ui): Plan cards with a feature comparison table; current plan highlighted with a 'Your plan' badge; monthly/annual billing toggle with a savings callout (e.g., '2 months free'); Stripe Payment Links or shadcn/ui pricing table component with a CTA for each tier.
- **Stripe Billing integration** (service): stripe.subscriptions.create() called server-side with the customer ID, Price ID, and optional trial_period_days. For self-serve billing management, stripe.billingPortal.sessions.create() generates a one-time URL pointing to Stripe's hosted Customer Portal — handles upgrades, downgrades, card updates, and cancellations without custom code.
- **Subscription webhook handler** (backend): A Supabase Edge Function or Next.js API route processes Stripe webhook events: customer.subscription.updated (plan change, status change), customer.subscription.deleted (cancelled), invoice.paid (renew confirmed), invoice.payment_failed (card declined). Each event updates the subscriptions table and may trigger a notification email.
- **Subscriptions table** (data): Supabase: subscriptions(id, user_id, stripe_subscription_id, stripe_customer_id, plan_id, status, current_period_start, current_period_end, cancel_at_period_end, created_at). RLS restricts SELECT to the row owner. Status values mirror Stripe: trialing, active, past_due, canceled, unpaid.
- **Plan access middleware** (backend): Server-side check before returning any premium content or API response: SELECT status FROM subscriptions WHERE user_id = auth.uid() AND status IN ('trialing', 'active'). Gates Next.js Server Actions, API routes, and Supabase Edge Functions. Never rely on client-side subscription state alone.
- **Customer Portal redirect** (backend): A short-lived one-time URL generated by stripe.billingPortal.sessions.create() with the customer ID and a return_url back to your app. The user is redirected to Stripe's hosted portal where they can upgrade, downgrade, update their card, view invoices, and cancel — Stripe handles all of this UI without any custom code.

## Data model

Three tables cover plans, subscriptions, and invoices. Run this in the Supabase SQL editor:

```sql
create table public.plans (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  stripe_price_id text not null unique,
  amount_cents integer not null check (amount_cents >= 0),
  currency text not null default 'usd',
  interval text not null check (interval in ('month', 'year')),
  features jsonb not null default '[]',
  created_at timestamptz not null default now()
);

create table public.subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null unique,
  plan_id uuid references public.plans(id),
  stripe_subscription_id text unique,
  stripe_customer_id text not null,
  status text not null default 'trialing'
    check (status in ('trialing', 'active', 'past_due', 'canceled', 'unpaid')),
  current_period_start timestamptz,
  current_period_end timestamptz,
  cancel_at_period_end boolean not null default false,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table public.subscriptions enable row level security;

create policy "Users can view own subscription"
  on public.subscriptions for select
  using (auth.uid() = user_id);

create table public.invoices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  stripe_invoice_id text unique not null,
  amount_paid integer not null,
  currency text not null,
  pdf_url text,
  status text not null,
  created_at timestamptz not null default now()
);

alter table public.invoices enable row level security;

create policy "Users can view own invoices"
  on public.invoices for select
  using (auth.uid() = user_id);

create index subscriptions_user_status_idx
  on public.subscriptions (user_id, status);

create index invoices_user_created_idx
  on public.invoices (user_id, created_at desc);
```

The UNIQUE constraint on user_id in the subscriptions table enforces one active subscription per user. The UNIQUE on stripe_subscription_id makes your webhook handler idempotent. The invoices table stores Stripe's hosted receipt PDF URL so users can download invoices without you generating them.

## Build paths

### Lovable — fit 5/10, 4–6 hours

Best AI-tool path for SaaS subscription billing — Lovable's native Stripe connector generates Edge Functions for subscription creation and Customer Portal redirect, plus the Supabase schema, in one prompt.

1. Create a new Lovable project, connect Lovable Cloud (Supabase and auth provisioned automatically)
2. Go to Cloud tab → Connectors → Stripe and connect your Stripe account; configure the Customer Portal in your Stripe Dashboard before coding
3. Paste the prompt below in Agent Mode — generates the pricing page, subscription Edge Functions, webhook handler, and billing account page
4. Click Publish — Stripe subscription flows and Customer Portal require the published URL; the in-editor preview iframe will not work for payment testing
5. In Stripe Dashboard, register your published URL as a webhook endpoint with events: customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed; add the signing secret as STRIPE_WEBHOOK_SECRET in Lovable Cloud → Secrets
6. Insert your plan rows into the plans table via Supabase Table Editor with the correct Stripe Price IDs from your Stripe Dashboard

Starter prompt:

```
Build a complete subscription management system for a SaaS app using Stripe Billing and Supabase. Create three database tables: plans (id, name, stripe_price_id, amount_cents, currency, interval month|year, features jsonb), subscriptions (id, user_id, plan_id, stripe_subscription_id, stripe_customer_id, status trialing|active|past_due|canceled|unpaid, current_period_start, current_period_end, cancel_at_period_end boolean, created_at, updated_at), invoices (id, user_id, stripe_invoice_id, amount_paid, currency, pdf_url, status, created_at). Enable RLS on subscriptions and invoices: users can only view their own rows. Create a pricing page with three plan cards (Starter $29/mo, Pro $79/mo, Business $199/mo) with feature comparison lists and a monthly/annual toggle that switches between the corresponding Stripe Price IDs. Include a 'Your plan' badge on the current plan card. Create a Supabase Edge Function to create a Stripe subscription: look up the stripe_price_id from the plans table using the plan_id, create or retrieve a Stripe Customer for the user_id (store stripe_customer_id in subscriptions table), call stripe.subscriptions.create() with customer, price, and trial_period_days: 14, return the client_secret for Stripe Elements confirmation. Create a webhook Edge Function that processes: customer.subscription.updated (update subscriptions row with new status, period dates, cancel_at_period_end), customer.subscription.deleted (set status to canceled), invoice.paid (insert invoices row with pdf_url from invoice.invoice_pdf), invoice.payment_failed (set status to past_due, prepare dunning email). Verify webhook signature with constructEventAsync() using raw Uint8Array body. Make handler idempotent: check subscription.updated_at before writing. Create a billing account page showing: current plan name, status badge, renewal date (current_period_end formatted), cancel_at_period_end warning ('Cancels on [date]' with a reactivate button if true), a 'Manage billing' button that calls an Edge Function to generate a Stripe Customer Portal session URL and redirects the user, and an invoices table with date, amount, status, and PDF download link. Add server-side plan access check in any Edge Function serving premium content: SELECT status FROM subscriptions WHERE user_id = auth.uid() AND status IN ('trialing','active'). Specify monthly Stripe Price IDs and annual Stripe Price IDs separately. Include upgrade/downgrade via the Customer Portal only — do not build custom proration UI.
```

Limitations:

- Subscription management and Customer Portal require the published Lovable URL — the in-editor preview iframe blocks Stripe redirects
- Complex proration logic for immediate upgrades with invoice preview (stripe.invoices.retrieveUpcoming()) may require manual Edge Function additions beyond the generated code
- Mobile subscriptions via RevenueCat/StoreKit are not in scope for this path — web-only Stripe Billing

### V0 — fit 4/10, 5–8 hours

Excellent for SaaS apps already built in Next.js — V0 generates a beautiful pricing table with shadcn/ui and the Next.js API routes for subscription creation and Customer Portal with Server Actions for plan gating.

1. Prompt V0 with the spec below to generate the pricing page component, subscription API routes, and billing account page
2. In Vercel dashboard, add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_SUPABASE_URL, and NEXT_PUBLIC_SUPABASE_ANON_KEY as environment variables
3. Run the SQL schema from this page in the Supabase SQL editor and insert your plan rows with Stripe Price IDs
4. Deploy to Vercel and register the webhook endpoint in Stripe Dashboard
5. Configure the Stripe Customer Portal in your Stripe Dashboard (what billing actions are allowed, which products are manageable)
6. Test the complete plan lifecycle: subscribe, upgrade, downgrade via portal, cancel, and confirm each webhook event updates your subscriptions table correctly

Starter prompt:

```
Build a subscription management system for a Next.js 14 App Router SaaS. Pricing page app/(marketing)/pricing/page.tsx: three plan cards (Starter $29/mo, Pro $79/mo, Business $199/mo) with feature lists using shadcn/ui Card; annual/monthly toggle with a '2 months free' badge for annual; 'Get started' CTA per plan. API route app/api/subscriptions/create/route.ts: POST with planId; look up stripe_price_id from Supabase plans table; create or retrieve a Stripe Customer using the session user_id (upsert into Supabase subscriptions.stripe_customer_id); call stripe.subscriptions.create() with customer, price, trial_period_days: 14; return {subscriptionId, clientSecret} for Stripe Elements confirmation. API route app/api/subscriptions/portal/route.ts: POST; retrieve stripe_customer_id from subscriptions table for the current user; call stripe.billingPortal.sessions.create() with return_url; return {url}. API route app/api/webhooks/stripe/route.ts: read raw Buffer body; verify with stripe.webhooks.constructEvent(); handle customer.subscription.updated (upsert subscriptions row with status, period dates, cancel_at_period_end), customer.subscription.deleted (set status canceled), invoice.paid (upsert invoices row with pdf_url), invoice.payment_failed (set status past_due). Make idempotent: use ON CONFLICT (stripe_subscription_id) DO UPDATE. Server Action checkSubscription(): SELECT status FROM subscriptions WHERE user_id = auth.uid() AND status IN ('trialing','active') LIMIT 1; use in every Server Component rendering premium content. Billing account page app/account/billing/page.tsx: current plan, renewal date, cancels-on date if cancel_at_period_end, 'Manage billing' button that calls the portal route, invoice history table with PDF links.
```

Limitations:

- No auto-provisioning — Stripe and Supabase must be wired manually in Vercel Environment Variables
- SSR hydration issues arise if subscription status is read from localStorage instead of server; prompt explicitly to use Server Components for all subscription state
- V0 may not generate all Stripe webhook event types — verify coverage of customer.subscription.updated, deleted, invoice.paid, and invoice.payment_failed in the generated code

### Flutterflow — fit 3/10, 1–2 days

Mobile subscription UI works well in FlutterFlow — plan cards, billing screen, and RevenueCat entitlements via custom code blocks for iOS/Android subscriptions.

1. Design the pricing screen in FlutterFlow: plan cards with pricing, feature lists, and a subscribe CTA button
2. Add a Custom Action SubscribePlan that calls Purchases.purchasePackage() from the RevenueCat Flutter SDK with the package identifier matching your App Store and Play Store configuration
3. Add a Custom Action CheckSubscription that calls Purchases.getCustomerInfo() and reads the active entitlement status into an App State variable checked before premium screens load
4. Add a 'Manage billing' button that opens a WebView or browser redirect to your Stripe Customer Portal URL (generated by your backend) for web billing, or calls the native system subscription settings for iOS/Android
5. Wire a Supabase API call action to record the subscription in your database on RevenueCat webhook event (handled by your backend Edge Function)
6. In FlutterFlow Settings → Permissions, ensure you have the correct in-app purchase entitlements configured for iOS in-app purchase review

Limitations:

- Stripe Customer Portal is a web page — redirecting mobile users to a browser tab for billing management is poor UX; native subscription management requires navigating to iOS Settings or Google Play account
- RevenueCat Flutter SDK requires custom code blocks; no visual RevenueCat widget in FlutterFlow
- Full subscription event handling (renewal, expiry, dunning) must be implemented in a backend webhook handler outside FlutterFlow
- FlutterFlow Pro or Teams plan required for custom code export

### Custom — fit 5/10, 1–3 weeks

Full billing flexibility — metered usage-based billing, multi-seat enterprise plans, custom trial logic, volume discounts, and annual contract invoicing all require custom implementation.

1. Usage-based billing with Stripe Meter API — report usage events server-side; Stripe aggregates and invoices automatically at period end
2. Multi-seat team subscriptions with seat management UI: add/remove team members, prorate seat additions mid-cycle using stripe.invoices.retrieveUpcoming() preview
3. Enterprise annual contracts with manual invoice creation via Stripe and net-30 payment terms
4. Custom dunning sequences: grace period email on day 1 of failure, card update nudge on day 3, final notice on day 7, then status downgrade

Limitations:

- Stripe Billing has significant depth — proration, pending invoice items, schedule phases, and trial extensions each have edge cases that take days to test correctly
- Minimum 1–3 weeks for a production-grade implementation; add 1 week if metered billing is required

## Gotchas

- **Subscription status checked client-side only — users manipulate it after lapse** — AI tools often generate a React context that stores subscription status in localStorage or a cookie, initialized once on login. When a subscription lapses or a payment fails, Stripe sends a webhook that updates your database — but the client-side context is never refreshed. A user can keep accessing premium features indefinitely by simply not refreshing the page, or by clearing and re-setting localStorage to the last known 'active' value. Fix: Always verify subscription status server-side before returning premium content. In Next.js: query Supabase inside a Server Component or Server Action — never read subscription status from a cookie or localStorage alone. Call SELECT status FROM subscriptions WHERE user_id = auth.uid() AND status IN ('trialing','active') on every protected route render.
- **Duplicate subscription.updated webhook events — status written multiple times** — Stripe retries webhook delivery on any 5xx response or timeout. If your handler processes slowly (over 5 seconds), Stripe may retry while the first delivery is still running. Two concurrent writes to the same subscription row can leave the status in an inconsistent intermediate state. AI-generated handlers that use separate SELECT then UPDATE (not atomic) are vulnerable to this race condition. Fix: Use INSERT ... ON CONFLICT (stripe_subscription_id) DO UPDATE SET status = EXCLUDED.status, updated_at = EXCLUDED.updated_at, current_period_end = EXCLUDED.current_period_end. This single atomic SQL statement wins the race — the last writer wins and there is no window between check and write.
- **cancel_at_period_end not reflected in UI — subscription shows 'Active' after cancel** — When a user cancels their subscription in the Stripe Customer Portal, Stripe sets cancel_at_period_end=true but does not immediately change the status to 'canceled' — the subscription remains 'active' until the period ends. AI-generated UIs that only check status='active' show no cancellation state, confusing users who think their cancellation did not work and prompting them to contact support or cancel again. Fix: Explicitly check cancel_at_period_end in your UI: if subscriptions.cancel_at_period_end is true, display 'Active — Cancels on [current_period_end formatted date]' with a 'Reactivate subscription' button that calls stripe.subscriptions.update() with cancel_at_period_end: false.
- **Proration charge surprises users on upgrade** — When a user upgrades from a lower to a higher plan mid-cycle, Stripe immediately prorates the difference by default — creating a charge the user was not expecting. For example, upgrading on day 15 of a 30-day cycle results in a charge for half the difference between plans. Users who see an unexpected charge often dispute it as fraud. Fix: Before confirming an upgrade, call stripe.invoices.retrieveUpcoming({ customer: stripeCustomerId, subscription: subscriptionId, subscription_items: [{ id: itemId, price: newPriceId }] }) and display the proration amount to the user: 'You will be charged $X today for the plan difference. Your next full billing date is [date].' Only proceed after explicit user confirmation.
- **Premium feature access not revoked after payment failure** — invoice.payment_failed fires when Stripe cannot charge the subscription renewal. AI-generated webhook handlers often update the subscription status to 'past_due' in the database but forget to add 'past_due' to the allowed statuses list in the plan access middleware — users continue accessing premium features through the entire Stripe dunning retry window (up to 7 days) without any access restriction. Fix: Decide explicitly: does 'past_due' get premium access or not? Stripe's default is a grace period where access continues during retry. If you want this behavior, add 'past_due' to the allowed statuses in your middleware. If not, exclude it. Whatever you decide, implement a 'payment required' banner for past_due users showing the next retry date and a 'Update payment method' link to the Customer Portal.

## Best practices

- Always verify subscription status server-side on every protected route render — never trust client-side state, localStorage, or cookies for access decisions
- Make your webhook handler idempotent using INSERT ... ON CONFLICT (stripe_subscription_id) DO UPDATE — Stripe retries webhooks and your handler must be safe to call multiple times
- Display cancel_at_period_end in the UI as 'Cancels on [date]' with a reactivate button — users who see 'Active' after cancelling will open support tickets thinking the cancellation failed
- Show a proration preview before confirming any upgrade using stripe.invoices.retrieveUpcoming() — surprise charges are the top cause of subscription churn and chargebacks
- Use the Stripe Customer Portal for all self-serve billing changes — upgrading, downgrading, card updates, and invoice downloads are handled by Stripe UI with zero custom code
- Store the Stripe invoice PDF URL in your invoices table so users can download receipts without you generating them
- Implement a 'payment required' banner for past_due subscriptions with a direct link to the Customer Portal — dunning emails alone recover only 40–60% of failed charges; in-app prompts significantly improve recovery
- Insert Stripe Price IDs in your plans table and look them up server-side when creating subscriptions — never pass Price IDs from the client where they could be manipulated

## Frequently asked questions

### How do I let users upgrade or downgrade without contacting support?

Use the Stripe Customer Portal — a Stripe-hosted self-serve page generated with a one-time URL from stripe.billingPortal.sessions.create(). Configure it in the Stripe Dashboard to allow plan changes, card updates, and cancellations. Your users get a full billing management interface; you write zero custom UI for it. Wire the portal URL generation to a 'Manage billing' button in your account settings page.

### What happens when a payment fails — are users immediately locked out?

By default, no. Stripe enters a retry sequence (dunning) defined in your Stripe Dashboard: it retries the charge on days 1, 3, 5, and 7 after the failure. During this time, the subscription status is 'past_due' but remains active. After all retries fail, status becomes 'canceled'. You decide what 'past_due' means for your access middleware — most SaaS products grant a grace period and show an in-app banner prompting card update.

### Can I offer a free trial without requiring a credit card?

Yes. Pass trial_period_days to stripe.subscriptions.create() and do not collect payment information upfront — create the subscription with payment_behavior: 'default_incomplete' and collect a card only at trial end. Stripe handles the trial-to-paid transition and fires a customer.subscription.updated event when the first invoice is generated. Be aware: trials without card collection have higher conversion friction at trial end.

### How do I handle annual vs monthly pricing correctly?

Create separate Stripe Price objects for each interval — one monthly price and one annual price per plan. Store both stripe_price_id values in your plans table (e.g., stripe_price_id_monthly and stripe_price_id_annual). The pricing page toggle switches which Price ID is passed to the subscription creation endpoint. Never calculate annual pricing client-side from the monthly rate — use Stripe's Price objects so invoicing is always correct.

### What's proration and how does it affect charges?

Proration is Stripe's mechanism for handling mid-cycle plan changes. When a user upgrades on day 15 of a 30-day cycle, Stripe credits them for the unused days of the old plan and charges for the remaining days of the new plan — resulting in an immediate partial charge. Stripe calculates this automatically. Always show users the proration amount before confirming a plan change by calling stripe.invoices.retrieveUpcoming() and displaying the next charge amount.

### Can I pause a subscription instead of cancelling?

Yes. Stripe supports subscription pausing via stripe.subscriptions.update() with pause_collection: { behavior: 'void' or 'mark_uncollectable' }. This stops invoicing for a specified number of months without cancelling the subscription. The Customer Portal can be configured to offer pausing as a cancellation alternative. Use this as a retention offer: 'Pause for 1 month instead of cancelling' shown in the cancellation flow typically recovers 15–25% of would-be churners.

### How do I give users a PDF receipt of their invoices?

Stripe generates PDF invoices automatically for every subscription charge. When your invoice.paid webhook fires, the event payload includes invoice.invoice_pdf — a URL to the Stripe-hosted PDF. Store this URL in your invoices table. Display it as a download link in your billing account page. Stripe hosts the PDF indefinitely, so there is no expiry concern. You do not need to generate or store PDFs yourself.

### Do I need Stripe Billing or can I use plain Payment Intents?

For true recurring subscriptions, use Stripe Billing — it handles the invoicing schedule, retry logic, proration, and trial management that you would have to build yourself with plain Payment Intents. Plain Payment Intents are for one-time charges. If you want to avoid Stripe Billing's 0.5% fee and manage your own recurring schedule (e.g., call Payment Intents on a cron job), you lose all the built-in dunning, proration, and Customer Portal functionality and take on significant engineering debt.

---

Source: https://www.rapidevelopers.com/app-features/subscription-management
© RapidDev — https://www.rapidevelopers.com/app-features/subscription-management
