Feature spec
IntermediateCategory
payments-commerce
Build with AI
4–8 hours with Lovable or V0
Custom build
1–3 weeks custom dev
Running cost
$5–20/mo (100 users) · $30–100/mo (1,000 users)
Works on
Everything it takes to ship Subscription Management — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Pricing page with a clear plan comparison table — feature differences between tiers should be scannable in under 10 seconds
- One-click upgrade or downgrade with immediate access change — no friction or manual process between plan selection and feature availability
- Self-serve cancellation with a retention offer (pause for 1 month as an alternative to cancelling) — removes the 'I have to email support' friction that inflates churn
- Invoice history with PDF download link — users need receipts for business expense claims and tax purposes
- Credit card update without losing the subscription — adding a new card does not interrupt billing or reset the renewal date
- Clear renewal date and next charge amount displayed prominently in account settings — no surprises on billing day
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
UIPlan 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.
Note: Use Stripe Price IDs directly in your pricing page component so the Subscribe button passes the correct Price ID to the subscription creation endpoint — never calculate prices client-side.
Stripe Billing integration
Servicestripe.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.
Note: The Customer Portal is the fastest way to handle self-serve billing. Configure it in the Stripe Dashboard under Billing → Customer Portal before generating portal session URLs in code.
Subscription webhook handler
BackendA 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.
Note: Stripe retries webhooks for 72 hours on 5xx responses. Make the handler idempotent: check the event timestamp against the subscription's updated_at before writing — discard events older than the last known state.
Subscriptions table
DataSupabase: 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.
Note: Add a UNIQUE constraint on stripe_subscription_id — prevents duplicate rows from race-condition webhook delivery.
Plan access middleware
BackendServer-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.
Note: The most common billing security failure is checking subscription status in React context or localStorage — which users can manipulate — instead of querying the database server-side on every protected request.
Customer Portal redirect
BackendA 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.
Note: Customer Portal sessions expire after 5 minutes. Generate the URL on demand when the user clicks 'Manage billing', not on page load. Display a spinner while the redirect URL is being created.
The data model
Three tables cover plans, subscriptions, and invoices. Run this in the Supabase SQL editor:
1create table public.plans (2 id uuid primary key default gen_random_uuid(),3 name text not null,4 stripe_price_id text not null unique,5 amount_cents integer not null check (amount_cents >= 0),6 currency text not null default 'usd',7 interval text not null check (interval in ('month', 'year')),8 features jsonb not null default '[]',9 created_at timestamptz not null default now()10);1112create table public.subscriptions (13 id uuid primary key default gen_random_uuid(),14 user_id uuid references auth.users(id) on delete cascade not null unique,15 plan_id uuid references public.plans(id),16 stripe_subscription_id text unique,17 stripe_customer_id text not null,18 status text not null default 'trialing'19 check (status in ('trialing', 'active', 'past_due', 'canceled', 'unpaid')),20 current_period_start timestamptz,21 current_period_end timestamptz,22 cancel_at_period_end boolean not null default false,23 created_at timestamptz not null default now(),24 updated_at timestamptz not null default now()25);2627alter table public.subscriptions enable row level security;2829create policy "Users can view own subscription"30 on public.subscriptions for select31 using (auth.uid() = user_id);3233create table public.invoices (34 id uuid primary key default gen_random_uuid(),35 user_id uuid references auth.users(id) on delete cascade not null,36 stripe_invoice_id text unique not null,37 amount_paid integer not null,38 currency text not null,39 pdf_url text,40 status text not null,41 created_at timestamptz not null default now()42);4344alter table public.invoices enable row level security;4546create policy "Users can view own invoices"47 on public.invoices for select48 using (auth.uid() = user_id);4950create index subscriptions_user_status_idx51 on public.subscriptions (user_id, status);5253create index invoices_user_created_idx54 on public.invoices (user_id, created_at desc);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Create a new Lovable project, connect Lovable Cloud (Supabase and auth provisioned automatically)
- 2Go to Cloud tab → Connectors → Stripe and connect your Stripe account; configure the Customer Portal in your Stripe Dashboard before coding
- 3Paste the prompt below in Agent Mode — generates the pricing page, subscription Edge Functions, webhook handler, and billing account page
- 4Click Publish — Stripe subscription flows and Customer Portal require the published URL; the in-editor preview iframe will not work for payment testing
- 5In 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
- 6Insert your plan rows into the plans table via Supabase Table Editor with the correct Stripe Price IDs from your Stripe Dashboard
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.Where this path bites
- 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
Third-party services you'll need
Three services cover the full subscription stack. Stripe Billing is mandatory; RevenueCat is needed only for mobile; Resend or SendGrid handle dunning emails.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Stripe Billing | Subscription lifecycle, recurring invoice generation, Customer Portal for self-serve billing management, dunning retry logic | Free account; test mode unlimited | 0.5% of recurring revenue (capped) plus 2.9% + $0.30 per charge |
| RevenueCat | Mobile subscription management — wraps StoreKit 2 (iOS) and Google Play Billing Library 5 (Android); cross-platform entitlement sync and webhooks | Free up to $2,500 MRR | 1% of revenue above threshold (approx) |
| Resend / SendGrid | Dunning emails (payment failed, card expiring), billing notification emails (upcoming renewal, invoice available) | Resend: 100 emails/day free; SendGrid: 100 emails/day free | $20–25/mo for moderate volume (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Stripe Billing fee (~0.5% of MRR) + Supabase free tier + Resend free tier for emails. Minimal fixed cost — all percentage-based at this scale.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Subscription status checked client-side only — users manipulate it after lapse
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
AI tools cover standard plan-based subscriptions reliably. These billing architectures require custom development:
- Usage-based billing (per API call, per active seat, per GB processed) — Stripe Meter API integration and usage event reporting require custom server-side instrumentation throughout your product
- Enterprise annual contracts with manual invoicing, net-30 payment terms, and custom contract line items — Stripe Invoicing API with payment terms requires bespoke implementation
- Multi-seat team subscriptions with per-seat proration and a seat management UI showing who is on the account and how many seats remain
- Subscription gifting or account transfer between users — Stripe does not natively support subscription transfer; requires custom logic to cancel one subscription and create another with proration handling
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds subscription management into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.