TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Build mode and get a full recurring billing layer: plans table, Stripe Checkout, customer portal, JWT-based TierGate component, and webhook-driven subscription state with raw-body verification and idempotency. Wire this onto any existing Lovable app. Expect 1–2 days and ~150–250 credits on Pro $25/mo.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores plans (seeded), subscriptions (webhook-managed), stripe_events (idempotency), and profiles.tier (mirror for queries).
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you will see an empty Table Editor
- 3Leave it empty — the starter prompt creates all tables, seeds the plans, and generates the apply_subscription_event() function
Auth
Email+password sign-in for users. Tier is stored in JWT app_metadata so TierGate reads it from the JWT on every render without hitting the database.
- 1Cloud tab → Users & Auth
- 2Confirm Email is enabled (it is by default)
- 3Set 'Allow new users to sign up' to ON — users self-register on the pricing page
- 4After launch, test that a new signup defaults to the free tier
Edge Functions
Hosts four Stripe Edge Functions: create-checkout-session, create-portal-session, stripe-webhook (raw body + constructEventAsync), and sync-subscription-from-stripe (manual fallback).
- 1Cloud tab → Edge Functions
- 2No manual setup needed — the starter prompt scaffolds all four function files
- 3After the prompt runs, add Stripe secrets before deploying (see Secrets section below)
Secrets (Cloud tab → Secrets)
STRIPE_SECRET_KEYPurpose: Allows Edge Functions to create Checkout sessions, Portal sessions, and retrieve subscription data
Where to get it: https://dashboard.stripe.com/apikeys — use test-mode secret key (sk_test_...) during build, switch to live (sk_live_...) before launch
STRIPE_WEBHOOK_SECRETPurpose: Verifies incoming Stripe webhook events — without this, any attacker can forge subscription events
Where to get it: https://dashboard.stripe.com/webhooks — create a webhook pointing to your deployed Edge Function URL + /stripe-webhook, then copy the Signing secret (whsec_...)
STRIPE_PRICE_ID_PROPurpose: Passed to Stripe Checkout to charge the Pro monthly plan
Where to get it: https://dashboard.stripe.com/products — create a recurring 'Pro' product with your monthly price, copy the Price ID (price_...)
STRIPE_PRICE_ID_TEAMPurpose: Passed to Stripe Checkout to charge the Team monthly plan
Where to get it: https://dashboard.stripe.com/products — create a recurring 'Team' product with your monthly price, copy the Price ID
RESEND_API_KEYPurpose: Sends welcome, renewal, payment-failed, and cancellation transactional emails
Where to get it: https://resend.com/api-keys — create an API key with full sending permissions
Preflight checklist
- You are in a Lovable project that already has authentication working — this kit adds billing on top of an existing auth flow
- You are on the Pro $25/mo plan or willing to upgrade — the full chain runs ~150–250 credits and Free caps at ~30/month
- You have a Stripe account (free at stripe.com). Start in test mode. Live billing is not needed until launch.
- You have decided on your tier names and pricing before running the starter prompt — the plans table is seeded with those values. Example: free ($0), pro ($20/mo), team ($50/mo)
The starter prompt
Copy this. Paste it into Lovable Build mode (the default chat at the bottom-left of the editor). Hit send.
Add a recurring Stripe subscription billing system to this Lovable app. Use the standard @supabase/supabase-js client and Deno-based Edge Functions. Do NOT use any import called lovable-payment — it does not exist.
Create one SQL migration file at supabase/migrations/0002_subscription_schema.sql (or 0001 if this is a fresh project) with the following:
1. Table: plans
- id text PRIMARY KEY (e.g., 'free', 'pro', 'team')
- display_name text NOT NULL
- price_cents int NOT NULL
- interval text CHECK (interval IN ('month','year'))
- stripe_price_id text NOT NULL
- features jsonb DEFAULT '{}' (e.g., {"max_records": 1000, "ai_credits": 100, "team_seats": 5})
- RLS: public SELECT (anyone can see plans — needed for pricing page)
- Seed 3 rows: {id: 'free', display_name: 'Free', price_cents: 0, interval: 'month', stripe_price_id: 'price_placeholder_free', features: {"max_records": 100, "ai_credits": 10}}, {id: 'pro', display_name: 'Pro', price_cents: 2000, interval: 'month', stripe_price_id: Deno.env.get('STRIPE_PRICE_ID_PRO') ?? 'price_placeholder_pro', features: {"max_records": 1000, "ai_credits": 100}}, {id: 'team', display_name: 'Team', price_cents: 5000, interval: 'month', stripe_price_id: Deno.env.get('STRIPE_PRICE_ID_TEAM') ?? 'price_placeholder_team', features: {"max_records": 10000, "ai_credits": 500, "team_seats": 10}}
2. Table: subscriptions
- id uuid PRIMARY KEY DEFAULT gen_random_uuid()
- user_id uuid NOT NULL REFERENCES auth.users(id)
- stripe_customer_id text NOT NULL
- stripe_subscription_id text UNIQUE
- plan_id text REFERENCES plans(id)
- status text CHECK (status IN ('trialing','active','past_due','canceled','unpaid','incomplete'))
- current_period_start timestamptz
- current_period_end timestamptz
- cancel_at_period_end bool DEFAULT false
- canceled_at timestamptz
- created_at timestamptz DEFAULT now()
- RLS: Enable RLS. SELECT: own row (user_id = auth.uid()). No client INSERT/UPDATE.
3. Table: stripe_events
- id uuid PRIMARY KEY DEFAULT gen_random_uuid()
- stripe_event_id text UNIQUE NOT NULL
- event_type text NOT NULL
- received_at timestamptz DEFAULT now()
- processed_at timestamptz
- RLS: No client access.
4. Add tier column to profiles table (or create it if it does not exist):
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS tier text NOT NULL DEFAULT 'free' CHECK (tier IN ('free','pro','team'));
This is a read-only mirror of app_metadata.tier — never updated by client code.
5. Create SECURITY DEFINER plpgsql function apply_subscription_event(p_user_id uuid, p_plan_id text, p_status text, p_stripe_customer_id text, p_stripe_subscription_id text, p_current_period_start timestamptz, p_current_period_end timestamptz, p_cancel_at_period_end bool)
This function:
- Upserts into subscriptions (update if stripe_subscription_id matches, insert if new)
- Updates profiles.tier = p_plan_id WHERE id = p_user_id
- Returns void
SECURITY DEFINER so it can write to subscriptions bypassing RLS.
Build the following pages and components:
1. /pricing: 3-column pricing table. Render plans from Supabase (SELECT * FROM plans ORDER BY price_cents). Show 'Current plan' badge if the signed-in user's app_metadata.tier matches. 'Start free' button links to /signup. 'Go Pro' and 'Go Team' buttons call create-checkout-session Edge Function with the plan_id. Style: emerald-600 primary, violet-500 for 'Most popular' Pro column highlight, amber-500 for Team. Feature list reads from plans.features jsonb.
2. /checkout/success: On mount, call await supabase.auth.refreshSession() to force JWT refresh with new tier. Show 'Welcome to [plan name]!' confirmation. Add a note: 'If features still appear locked, sign out and back in to refresh your session.' Link to /dashboard or the main app.
3. /account/billing: Show current plan (CurrentPlanCard), subscription status, current_period_end formatted as 'Renews [date]' or 'Cancels [date]' if cancel_at_period_end is true. 'Manage subscription' button calls create-portal-session Edge Function and redirects. For free-tier users, show an upgrade CTA.
4. /admin/customers (admin-only, protected by app_metadata.role='admin'):
- Paginated list of all users with an active/trialing subscription
- Columns: email, plan, status, current_period_end, stripe_customer_id (link to Stripe Dashboard)
- MRRStat card at top: SUM of plans.price_cents WHERE subscriptions.status='active'
- Churn rate last 30 days: count where canceled_at > now() - interval '30 days'
5. Component TierGate: wraps premium features. Props: requiredTier ('pro'|'team'), children, fallback (optional ReactNode). Logic: reads tier from supabase.auth.getUser() -> user.app_metadata.tier. If tier rank < requiredTier rank, renders fallback (default: an upgrade CTA card pointing to /pricing). If tier rank >= requiredTier, renders children. Export this component so it can be used anywhere in the app to gate features.
6. Component PricingTable: 3-column with feature matrix. Each feature row compares across plans by reading from plans.features jsonb.
7. Component CurrentPlanCard: shows current plan badge (color-coded by tier), status badge (active/past_due/canceled), next renewal date, and Manage Subscription button.
Edge Function scaffolds (bodies to be implemented in follow-up #1):
- supabase/functions/create-checkout-session/index.ts: stub
- supabase/functions/create-portal-session/index.ts: stub
- supabase/functions/stripe-webhook/index.ts: stub with correct import structure
- supabase/functions/sync-subscription-from-stripe/index.ts: stub
Deliverable: A working /pricing page with 3 plan columns, /account/billing showing current tier, /checkout/success with JWT refresh, TierGate component usable anywhere in the app, and four Edge Function stubs ready for the webhook follow-up.What this prompt generates
- Creates a SQL migration with 4 tables (plans, subscriptions, stripe_events, profiles tier column) and the apply_subscription_event() SECURITY DEFINER function
- Seeds 3 plan rows (free, pro, team) with feature jsonb and stripe price ID placeholders
- Builds /pricing with a dynamic 3-column PricingTable that reads from the plans table
- Generates TierGate component that wraps any feature with a tier check from JWT app_metadata
- Creates /account/billing with CurrentPlanCard and Manage Subscription flow
- Scaffolds /admin/customers with MRR stat card and subscriber list
- Produces four Edge Function stubs for the webhook follow-up
Paste into: Lovable Build mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0002_subscription_schema.sql4 tables + apply_subscription_event SECURITY DEFINER function + 3 seeded plan rows
src/layouts/AppLayout.tsxApp shell with billing navigation (if not already present)
src/components/PricingTable.tsx3-column tier comparison rendering from plans table
src/components/CurrentPlanCard.tsxCurrent plan status, renewal date, Manage Subscription button
src/components/TierGate.tsxFeature wrapper that reads JWT tier and shows upgrade CTA when tier is too low
src/pages/Pricing.tsx3-tier pricing page with Stripe checkout CTAs
src/pages/CheckoutSuccess.tsxPost-checkout redirect with forced JWT refresh
src/pages/AccountBilling.tsxCurrent subscription status and customer portal link
src/pages/admin/Customers.tsxAdmin view of all paying subscribers with MRR stat
supabase/functions/create-checkout-session/index.tsStripe Checkout session creator stub
supabase/functions/create-portal-session/index.tsStripe Customer Portal session creator stub
supabase/functions/stripe-webhook/index.tsWebhook handler stub with correct import structure
supabase/functions/sync-subscription-from-stripe/index.tsManual subscription sync utility stub
Routes
3-column pricing page with dynamic feature matrix and Stripe checkout CTAs
Post-payment confirmation with forced JWT refresh
Current plan, renewal date, and Manage Subscription portal link
Admin-only subscriber list with MRR stat (requires app_metadata.role='admin')
DB Tables
plans| Column | Type |
|---|---|
| id | text primary key |
| display_name | text not null |
| price_cents | int not null |
| interval | text |
| stripe_price_id | text not null |
| features | jsonb default '{}' |
RLS: Public SELECT — the pricing page reads this table as anon. No client writes.
subscriptions| Column | Type |
|---|---|
| id | uuid primary key |
| user_id | uuid not null references auth.users(id) |
| stripe_customer_id | text not null |
| stripe_subscription_id | text unique |
| plan_id | text references plans(id) |
| status | text |
| current_period_start | timestamptz |
| current_period_end | timestamptz |
| cancel_at_period_end | bool default false |
| canceled_at | timestamptz |
RLS: Own row SELECT only (user_id = auth.uid()). All writes via apply_subscription_event() SECURITY DEFINER function — never from client code. Stripe is the source of truth; this table is a mirror.
stripe_events| Column | Type |
|---|---|
| id | uuid primary key |
| stripe_event_id | text unique not null |
| event_type | text not null |
| received_at | timestamptz default now() |
| processed_at | timestamptz |
RLS: No client access. Used by the webhook handler to deduplicate events — prevents double-processing on Stripe retries.
profiles| Column | Type |
|---|---|
| id | uuid primary key references auth.users(id) |
| tier | text not null default 'free' |
RLS: Own row SELECT. Tier column is never written by client code — it is managed exclusively by the webhook handler via apply_subscription_event().
Components
PricingTable3-column tier comparison with feature matrix read from plans.features jsonb
CurrentPlanCardCurrent plan badge, subscription status, renewal/cancellation date
TierGateFeature wrapper: reads JWT tier, renders children if met or upgrade CTA if not
MRRStatAdmin card showing total active MRR from subscriptions
BillingHistoryTableInvoice list from Stripe via Edge Function (optional, added in follow-up)
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Implement Stripe webhook with raw body + constructEventAsync + idempotency
Working Stripe subscription billing: Checkout, Customer Portal, webhook handler with raw-body verification, idempotency, and tier sync via app_metadata
Implement the stripe-webhook Edge Function at supabase/functions/stripe-webhook/index.ts. This is the most critical part of the subscription system — do it exactly as described.
The function must:
1. Read raw body BEFORE any parsing: const body = await req.text();
2. Read signature: const sig = req.headers.get('stripe-signature');
3. Verify: const event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'));
Use constructEventAsync, NOT constructEvent. Deno's WebCrypto is async — the synchronous variant throws: 'SubtleCryptoProvider cannot be used in a synchronous context.'
DO NOT call req.json() before verification — JSON-parsing mutates the bytes and breaks signature verification.
4. Check idempotency: const {data: existing} = await supabaseAdmin.from('stripe_events').select('id').eq('stripe_event_id', event.id).maybeSingle(); if (existing) return new Response('Already processed', {status: 200});
5. Insert into stripe_events: await supabaseAdmin.from('stripe_events').insert({stripe_event_id: event.id, event_type: event.type});
6. Handle these event types:
checkout.session.completed:
- Extract userId from event.data.object.metadata.userId and planId from metadata.planId
- Extract stripeCustomerId, subscriptionId from event.data.object
- Call apply_subscription_event() with all fields and status='active'
- Call await supabaseAdmin.auth.admin.updateUserById(userId, {app_metadata: {tier: planId}})
customer.subscription.updated:
- If cancel_at_period_end=true: update subscriptions.cancel_at_period_end=true only, do NOT change tier
- If status='past_due': call apply_subscription_event with status='past_due'
- If status='active' (reactivated after past_due): call apply_subscription_event with status='active'
customer.subscription.deleted:
- Call apply_subscription_event with plan_id='free', status='canceled', canceled_at=now()
- Call await supabaseAdmin.auth.admin.updateUserById(userId, {app_metadata: {tier: 'free'}})
invoice.payment_failed:
- Update subscriptions.status='past_due'
- Send Resend email 'payment_failed' if RESEND_API_KEY is set
7. Return new Response('OK', {status: 200}) after processing.
Also implement create-checkout-session:
- Require authentication (read JWT from Authorization header)
- Accept {planId} from request body
- Get or create Stripe customer: check subscriptions for existing stripe_customer_id, otherwise call stripe.customers.create({email: user.email, metadata: {userId: user.id}})
- Create Stripe Checkout session: mode='subscription', line_items with quantity 1 of the plan's stripe_price_id (from plans table), success_url='/checkout/success', cancel_url='/pricing', metadata: {userId, planId}
- Return {url: session.url}
Also implement create-portal-session:
- Require authentication
- Fetch stripe_customer_id from subscriptions WHERE user_id = auth.uid()
- Call stripe.billingPortal.sessions.create({customer: stripe_customer_id, return_url: '/account/billing'})
- Return {url: session.url}
Deliverable: All three Edge Functions deployed. Test with Stripe test card 4242 4242 4242 4242 on the DEPLOYED lovable.app URL — not in preview. Check Stripe Dashboard → Webhooks → your endpoint to confirm events are being received and returning 200.When to use: Immediately after the starter prompt, before any test payments
Add Stripe customer portal and trial period
Self-serve customer portal for cancel/upgrade, optional trial period with email warning, and correct trial status display
Add two features:
1. Verify the create-portal-session Edge Function is deployed and working. On /account/billing, wire the 'Manage subscription' button: call create-portal-session, await the response URL, then window.location.href = url. Configure the Customer Portal in Stripe Dashboard → Billing → Customer Portal: enable 'Update subscription' (allow plan changes), 'Cancel subscription', and 'View invoice history'.
2. Add trial period support to create-checkout-session: add an optional trial_days parameter (default 0). If trial_days > 0, include subscription_data: {trial_period_days: trial_days} in the Checkout session. On the /pricing page, add an optional 'Start 14-day free trial' CTA for the Pro plan that passes trial_days=14.
3. Handle trial webhook events in stripe-webhook:
- customer.subscription.trial_will_end (fires 3 days before trial ends): send a Resend email 'trial_ending' warning the user their trial ends in 3 days and their card will be charged
- checkout.session.completed when a trial session completes: set status='trialing', tier to the trial plan
4. Update CurrentPlanCard on /account/billing to show trial status: 'Trial ends [date] — after that, $20/mo' instead of a renewal date.
Deliverable: Users can start a 14-day free trial via the Pro plan CTA. They see trial status on /account/billing. They receive a warning email 3 days before trial ends. After trial ends, Stripe auto-charges and the webhook sets their status to 'active'.When to use: After the webhook is verified working with a test payment
Send transactional billing emails via Resend
Transactional emails for welcome, renewal, payment failure, cancellation, and trial ending via Resend
Add billing transactional emails via Resend. RESEND_API_KEY must already be in Cloud Secrets. Create a helper function sendBillingEmail(userId, type, metadata) in the stripe-webhook Edge Function: - Fetch user email from profiles WHERE id = userId - POST to https://api.resend.com/emails with Authorization: Bearer RESEND_API_KEY - Templates (use plain text for reliability, no HTML required): 'welcome': Subject '[App Name] — Welcome to [plan]!', body: what they can now do, link to /dashboard 'renewed': Subject 'Your [plan] subscription has renewed', body: renewed until [date], link to /account/billing 'payment_failed': Subject 'Action required: update your payment method', body: 7-day grace period, link to Stripe portal CTA 'canceled': Subject 'Your [plan] subscription has ended', body: what they lose access to, link to /pricing to resubscribe 'trial_ending': Subject 'Your trial ends in 3 days', body: will be charged $X on [date], how to cancel if not interested Event mapping: - checkout.session.completed → 'welcome' - customer.subscription.updated (status='active', not first) → 'renewed' - invoice.payment_failed → 'payment_failed' - customer.subscription.deleted → 'canceled' - customer.subscription.trial_will_end → 'trial_ending' Return early without throwing if RESEND_API_KEY is not set — email is optional, billing still works without it. Deliverable: Each billing lifecycle event triggers the appropriate transactional email. Test by sending a test webhook from Stripe Dashboard → Webhooks → Send test event for each event type.
When to use: After webhook and portal are both verified working with test payments
Build admin customer view and MRR dashboard
Admin MRR dashboard and subscriber management view with Stripe deep-links
Expand the /admin/customers page with a full subscriber view and MRR analytics.
1. Subscriber list: paginated table (25 rows per page) with columns: email, plan (badge), status (badge: active=green, past_due=yellow, canceled=red), current_period_end or canceled_at, link to Stripe Dashboard customer (https://dashboard.stripe.com/customers/{stripe_customer_id}). Add filters: status dropdown, plan dropdown, date range for created_at.
2. MRR stat card: display as a large number at the top. Compute via Postgres RPC: CREATE OR REPLACE FUNCTION admin_subscription_stats() RETURNS TABLE(mrr_cents bigint, active_count int, past_due_count int, churned_last_30_days int) LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN RETURN QUERY SELECT SUM(p.price_cents)::bigint AS mrr_cents, COUNT(*) FILTER (WHERE s.status = 'active')::int AS active_count, COUNT(*) FILTER (WHERE s.status = 'past_due')::int AS past_due_count, COUNT(*) FILTER (WHERE s.canceled_at > now() - interval '30 days')::int AS churned_last_30_days FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.status IN ('active', 'past_due'); END; $$; Call this RPC on page mount and refresh every 60 seconds.
3. MRR display: format as '$X,XXX' (e.g., $1,240). Show month-over-month change if you have historical data (compare to same RPC last 30 days — skip if no prior data).
4. Protect /admin/customers: wrap with an AdminGuard component that checks app_metadata.role='admin' from the JWT. Redirect to /dashboard if role is missing.
Deliverable: Admin users can see a real-time MRR stat and a paginated, filterable list of all paying subscribers with links to their Stripe profiles.When to use: When you have 10+ paying customers and want visibility into revenue and churn
Add upgrade/downgrade between paid tiers
In-app plan upgrade/downgrade with Stripe proration and instant TierGate refresh
Add the ability for users to change between paid plans (e.g., Pro to Team) with Stripe proration.
1. On /account/billing, add a 'Change plan' section visible to active subscribers. Show available upgrade/downgrade options (exclude current plan and free tier). Each option shows new price and 'Prorated credit' note.
2. Create Edge Function supabase/functions/update-subscription/index.ts:
- Require authentication
- Accept {newPlanId} from request body
- Fetch the user's current stripe_subscription_id from subscriptions
- Fetch the new plan's stripe_price_id from plans WHERE id = newPlanId
- Call stripe.subscriptions.update(subscriptionId, {items: [{id: currentItemId, price: newPriceId}], proration_behavior: 'create_prorations'})
- Return {success: true}
- The stripe-webhook handler's customer.subscription.updated event will pick up the new plan_id and call apply_subscription_event automatically
3. Add the 'Change plan' button to /account/billing that calls update-subscription and shows a loading spinner while waiting. After success, call supabase.auth.refreshSession() and reload CurrentPlanCard to reflect the new plan.
4. Update the PricingTable on /pricing: for signed-in paid users, replace 'Go Pro' / 'Go Team' buttons with 'Upgrade' or 'Downgrade' buttons that call update-subscription instead of create-checkout-session.
Deliverable: A Pro subscriber can upgrade to Team with prorated billing. A Team subscriber can downgrade to Pro. The TierGate component updates within 2 seconds of the plan change.When to use: When you have multiple paid tiers and users start requesting plan changes
Add usage-based billing for metered features
Usage event tracking, per-period usage gate, and metered Stripe billing for variable features
Add usage tracking and metered billing for a variable feature like API calls or AI credits.
1. Create table usage_events:
- id uuid PRIMARY KEY DEFAULT gen_random_uuid()
- user_id uuid NOT NULL REFERENCES auth.users(id)
- kind text NOT NULL (e.g., 'api_call', 'ai_request', 'record_created')
- quantity int DEFAULT 1
- occurred_at timestamptz DEFAULT now()
- RLS: own row INSERT and SELECT; admin SELECT all.
2. Create a Postgres function record_usage(p_user_id uuid, p_kind text, p_quantity int) SECURITY DEFINER that inserts into usage_events and returns the user's total usage for the current billing period from subscriptions.current_period_start to now().
3. Update TierGate: add a UsageGate variant that checks current usage against the plan's feature limit. Example: {const {data} = await supabase.rpc('get_usage_this_period', {kind: 'ai_request'}); if (data.count >= plan.features.ai_credits) return <UpgradeCTA/>; } Show remaining usage in the gate fallback: 'You have used X of Y AI credits this period.'
4. In Stripe: create a metered price for the feature (e.g., $0.01 per API call above the plan limit). Create Edge Function supabase/functions/report-metered-usage/index.ts that runs nightly (via a scheduled Edge Function or pg_cron): SELECT user_id, COUNT(*) as quantity FROM usage_events WHERE kind = 'api_call' AND occurred_at >= current_period_start GROUP BY user_id; then call stripe.subscriptionItems.createUsageRecord(itemId, {quantity, timestamp, action: 'set'}) for each user.
Deliverable: Usage events are tracked per user. TierGate shows remaining credits. Users who exceed their plan's limit see a clear upgrade CTA with remaining count.When to use: When you want to charge for variable usage (AI requests, API calls, exports) on top of flat-rate plans
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
SubtleCryptoProvider cannot be used in a synchronous context. Use await constructEventAsync(...) instead of constructEvent(...)Lovable scaffolded the webhook with Node's synchronous stripe.webhooks.constructEvent API. Deno's WebCrypto is async-only — the sync variant throws on the first real webhook call. This has been verified live by a developer (tomokat) during an actual Lovable build session.
In stripe-webhook Edge Function, replace constructEvent with await constructEventAsync. Make the outer handler function async. Read the body first: const body = await req.text(); const event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')); DO NOT call req.json() at any point before or after verification — JSON-parsing corrupts the raw bytes and causes either a signature failure or a crypto error.Webhook signature verification failedEither the webhook secret in Cloud Secrets does not match the Signing secret in Stripe Dashboard, or the request body was parsed with req.json() before verification, mutating the bytes.
Manual fix
Go to Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret (whsec_...) → copy it. Then Cloud tab → Secrets → find STRIPE_WEBHOOK_SECRET → paste the new value. Redeploy the Edge Function. Re-test by clicking 'Send test webhook' in Stripe Dashboard. If still failing, add a console.log before verification to confirm req.text() is being called before any JSON parsing.
User paid but app still shows free tier features after checkoutThe webhook successfully updated the subscriptions table and profiles.tier, but did not call supabaseAdmin.auth.admin.updateUserById to update app_metadata.tier — the JWT still carries the old tier claim. TierGate reads from the JWT, not from profiles.
In the stripe-webhook handler for checkout.session.completed, after calling apply_subscription_event(), add: await supabaseAdmin.auth.admin.updateUserById(userId, {app_metadata: {tier: planId}}); Also, on the /checkout/success page, call await supabase.auth.refreshSession() to force the client JWT to refresh immediately. If users still see the old tier, add a visible message: 'Your upgrade is active — if features still appear locked, sign out and back in to refresh your session.'Stripe integration does not work in preview / Checkout never redirectsLovable's Cloud connector for Stripe is deploy-only — it does not function inside the in-editor preview iframe.
Manual fix
This is expected and documented by Lovable. Click Publish in the top-right toolbar to deploy to the temporary lovable.app URL. Configure your Stripe webhook endpoint in Stripe Dashboard to point to the deployed Edge Function URL (e.g., https://your-project.lovable.app/functions/v1/stripe-webhook). Test with card 4242 4242 4242 4242 in test mode on the deployed URL.
Same webhook event processed twice, duplicate subscription rows createdStripe retries webhook delivery on any non-200 response, and occasionally redelivers events for reliability. Without an idempotency check, your handler processes the same event twice.
Add idempotency at the top of the stripe-webhook handler, immediately after signature verification: const {data: existing} = await supabaseAdmin.from('stripe_events').select('id').eq('stripe_event_id', event.id).maybeSingle(); if (existing) { return new Response('Already processed', {status: 200}); } Then INSERT the event_id before processing. If the INSERT fails on a unique constraint (race condition), also return 200. Wrap insert + process in a try/catch.User cancels via portal but still sees paid features for 30 days (or: downgraded immediately when they should not be)When a user cancels, Stripe fires customer.subscription.updated with cancel_at_period_end=true — NOT customer.subscription.deleted. Many handlers incorrectly downgrade immediately on this event. The subscription stays active until the period ends, then customer.subscription.deleted fires.
In stripe-webhook handler for customer.subscription.updated: if event.data.object.cancel_at_period_end = true, do NOT change the user's tier — update only subscriptions.cancel_at_period_end = true so the /account/billing page can show 'Cancels on [date]'. Only downgrade tier to 'free' (and call updateUserById app_metadata) when customer.subscription.deleted fires. This is the correct cancellation lifecycle.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo recommended, with one top-up budgeted ($15 per 50 credits). Webhook + RLS + tier-sync iteration cycles are where credits go. Counter-data point: tomokat shipped a full Stripe + Supabase credit-topup system in under 10 credits in one session with the right prompts — with this kit that pace is possible.
Monthly run cost breakdown
~150–250 credits (starter ~70–100, six follow-ups ~370 if all done; most ship a working billing system with follow-ups #1, #2, and #3 for ~180) total| Item | Cost |
|---|---|
| Lovable Pro Only while iterating — drop to Free after launch | $25/mo |
| Supabase / Lovable Cloud Free tier: 500MB DB holds millions of subscription rows; you will never hit DB cap on the billing system itself | $0/mo at MVP scale |
| Stripe The dominant ongoing cost. At $20/mo × 100 subscribers = $2,000 MRR, Stripe takes ~$88. At $10K MRR, ~$320/mo. | 2.9% + 30¢ per charge |
| Resend Free up to 3K emails/month; $20/mo for 50K | $0–20/mo |
| Custom domain Optional | ~$10–15/yr |
Scaling notes: Stripe fees scale linearly with MRR — that is the only meaningful ongoing cost for this system. Supabase Free 500MB DB holds far more subscription rows than you will ever generate. The only DB scaling trigger is if you add heavy usage_events tracking in follow-up #6 — at 1M events/day you will want Supabase Pro for performance, not storage.
Production checklist
Steps to take before you share the URL with real users.
Stripe Live Mode
Switch from test to live Stripe keys
Cloud tab → Secrets. Replace STRIPE_SECRET_KEY with your live key (sk_live_...). Replace STRIPE_PRICE_ID_PRO and STRIPE_PRICE_ID_TEAM with price IDs from your live-mode products. Create a new webhook in Stripe Dashboard pointing to your production Edge Function URL and replace STRIPE_WEBHOOK_SECRET with the new live-mode signing secret.
Test live mode with a real card
Use your own card to subscribe to the Pro plan on the live /pricing page. Verify the webhook fires (check Stripe Dashboard → Webhooks → your endpoint → 200 responses), app_metadata.tier updates to 'pro', TierGate unlocks premium features. Then cancel via the portal and verify downgrade on customer.subscription.deleted.
Domain & SSL
Connect a custom domain
Lovable Dashboard → your project → Settings → Custom Domain. Add a CNAME at your DNS provider. SSL is automatic.
Update Stripe redirect URLs
After connecting your domain, update success_url and cancel_url in create-checkout-session, and return_url in create-portal-session to use your production domain instead of the lovable.app URL.
Verify sending domain in Resend
Resend Dashboard → Domains → Add Domain. Add the TXT and MX DNS records at your domain provider. Without domain verification, emails go to spam.
Update from address in email templates
In the sendBillingEmail helper, change from 'onboarding@resend.dev' (Resend sandbox) to your verified domain address (e.g., billing@yourdomain.com)
Monitoring
Set up Stripe webhook failure alerts
Stripe Dashboard → Developers → Webhooks → your endpoint → click the alert icon. Enable email alerts when webhook failure rate exceeds 5%. Webhook failures mean subscription state drifts — you want to know immediately.
Confirm idempotency table is working
After your first real subscription, check the stripe_events table: SELECT COUNT(*) FROM stripe_events; Each processed event should appear exactly once. If you see duplicates, your idempotency check has a race condition.
Frequently asked questions
Does this work on the Free Lovable plan?
Not for the full build. The Free plan caps at approximately 30 credits per month, which is exhausted during the starter prompt alone. You need Pro $25/mo to complete the full chain. That said, you can run the starter prompt on Free to see the schema and UI scaffold, then upgrade to Pro for the webhook follow-up. One developer (tomokat) shipped a full Stripe + Supabase credit-topup system in under 10 credits in a single session with the right prompts — so the cost varies widely based on how much iteration you need.
Why must Stripe be tested only after deploy, not in preview?
Lovable's in-editor preview runs in a sandboxed iframe that does not expose your Edge Functions to the internet. Stripe's webhook delivery requires a publicly reachable URL to POST events to. The preview URL is not reachable by Stripe. Click Publish in the top-right toolbar to deploy to the temporary lovable.app URL, then configure your Stripe webhook endpoint in the Stripe Dashboard to point to that URL. All Stripe testing — Checkout, portal, webhook events — must be done on the deployed URL.
What is the difference between cancel_at_period_end and immediate cancel?
When a user cancels via the Stripe Customer Portal, Stripe's default behavior is cancel_at_period_end = true. This means the subscription stays active until the end of the current billing period, then customer.subscription.deleted fires. Your webhook should NOT downgrade the user's tier when cancel_at_period_end becomes true — only when subscription.deleted fires. Immediate cancellation (cancel_at_period_end = false) happens only if you call stripe.subscriptions.cancel() with invoice_now=true, or if Stripe cancels due to repeated payment failures. The CurrentPlanCard component shows 'Cancels on [date]' to communicate the pending cancellation to the user.
How do I handle EU VAT or US sales tax?
The easiest path is to use Stripe Tax — enable it in your Stripe Dashboard under Settings → Tax, add your business address, and set automatic_tax: {enabled: true} in your create-checkout-session Edge Function. Stripe calculates and collects the correct tax for each customer's location automatically. For more complex international compliance (OSS registration in the EU, marketplace facilitator rules in the US), consider Paddle as an alternative to Stripe — Paddle acts as Merchant of Record and handles all tax obligations for you at 5% + 50¢ per transaction.
Should I store subscription state in Supabase or just query Stripe each time?
Store it in Supabase. Querying Stripe on every page load has three problems: latency (100–500ms per API call vs microseconds for a local DB query), rate limits (Stripe has per-second rate limits that you will hit under load), and dependency (if Stripe has an outage, your app breaks). The subscriptions table in this kit mirrors Stripe's subscription object, and the webhook keeps it in sync in real time. The one case to query Stripe directly is the sync-subscription-from-stripe utility function, which you call on the /account/billing page as a safety net if the webhook and DB get out of sync.
How do I migrate existing Stripe customers from another platform?
Export your customer and subscription data from Stripe (Stripe Dashboard → Customers → Export or use the Stripe API). For each customer, create a row in your subscriptions table with their stripe_customer_id, stripe_subscription_id, plan_id (map from their current Stripe price to your plan IDs), status, and current_period_end. Then call supabaseAdmin.auth.admin.updateUserById for each user to set their app_metadata.tier to match. You can do this as a one-time migration script run via the sync-subscription-from-stripe Edge Function — prompt Lovable with 'Add a /admin/migration route that reads all users from Stripe and syncs their subscription state to our subscriptions table.'
When does Paddle's 5% Merchant of Record fee become cheaper than handling tax myself?
Stripe Tax costs 0.5% of tax-collected transactions on top of the standard 2.9% + 30¢. For most founders, Stripe Tax is cheaper than Paddle (5% + 50¢) until you have significant international revenue with complex multi-jurisdiction VAT requirements — roughly above $50K MRR with 30%+ of revenue from the EU. Below that threshold, use Stripe Tax and handle quarterly VAT returns yourself or via an accountant. Above $100K MRR with heavy EU/UK/Canada traffic, Paddle's Merchant of Record model starts to pay for itself in saved accounting and compliance hours.
Can RapidDev build this subscription system for me end-to-end?
If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.