Skip to main content
RapidDev - Software Development Agency
Lovable PromptsB2B SaaSAdvanced

Build Payment Gateway Integration in Lovable

A complete Stripe integration — Checkout, customer portal, webhook reconciliation with idempotency, and a payments table with per-user RLS — added to any Lovable app in a single afternoon.

Time to MVP

~1 day

Credits

~80-150 credits for full chain

Difficulty

Advanced

Cloud features

3

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt below into Lovable Build mode and get a complete Stripe integration — checkout, customer portal, and a webhook handler that actually works in Deno. The two gotchas that break every other guide: you must read the request body with req.text() not req.json(), and you must call constructEventAsync not constructEvent. Both are non-negotiable in Deno Edge Functions. A documented build ran this entire flow in under 10 credits in one session.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores customers, payments, subscriptions, and the webhook_events idempotency log. The starter prompt creates all four tables via migration.

  1. 1Click the + button next to Preview to open the Cloud tab
  2. 2Click Database — the starter prompt creates all tables via migration
  3. 3After the migration runs, verify the UNIQUE index on payments.stripe_payment_intent_id and webhook_events.id exist in Cloud tab → Database → Indexes

Auth

Most checkouts attach to a signed-in user. The create-checkout-session function reads the caller's user ID from the Authorization header.

  1. 1Cloud tab → Users & Auth
  2. 2Enable Email sign-in (default — already on)
  3. 3Users who checkout should be signed in first — the /pricing page Buy CTAs redirect to /login if not authenticated

Edge Functions

Four Edge Functions handle checkout session creation, customer portal, webhook verification, and ensure-stripe-customer on signup.

  1. 1Cloud tab → Edge Functions — leave empty; the starter prompt and follow-ups generate all four functions
  2. 2After generating stripe-webhook, register the webhook endpoint in Stripe Dashboard → Developers → Webhooks
  3. 3Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET to Cloud tab → Secrets (see Secrets section) before any Stripe-related prompt

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: All Stripe API calls (create checkout session, customer portal, PaymentIntent) are authenticated with this key.

Where to get it: https://dashboard.stripe.com/apikeys — use the API Key icon in the Lovable chat input or paste directly in Cloud tab → Secrets. NEVER paste the secret key into a chat message — chat messages are logged and the key can be recovered later.

STRIPE_WEBHOOK_SECRET

Purpose: Used in constructEventAsync to verify that webhook events came from Stripe, not a third party spoofing your endpoint.

Where to get it: https://dashboard.stripe.com/webhooks → click your webhook endpoint → copy the Signing secret (whsec_...). Each endpoint has its own signing secret — test and live endpoints have different secrets.

RESEND_API_KEY

Purpose: Sends custom-branded receipt emails after successful payment if you prefer them over Stripe's built-in receipts.

Where to get it: https://resend.com/api-keys — create a key with Send access. Only needed for follow-up #7 (custom branded receipts).

Preflight checklist

  • You're in a fresh Lovable project, or you have an existing Lovable app that you want to add payments to
  • You have a Stripe account — go to https://dashboard.stripe.com/register to create one; activation (for live charges) requires tax ID and bank account but test mode works immediately
  • You have created at least one Stripe Product and Price in the Stripe Dashboard (Products → Add product → Add price). Note the Price ID (price_xxx) — you'll need it in create-checkout-session
  • You're on Pro $25/mo — the documented tomokat build ran under 10 credits, but budget 80-150 for iteration on the webhook async/raw-body issues that most builders hit

The starter prompt — paste this first

Copy this. Paste it into Lovable Agent Mode (the default chat at the bottom-left of the editor). Hit send.

lovable-agent-mode.txt
~30-50 credits
Add a complete Stripe payment integration to this Lovable app. Stack assumptions: React + Vite + TypeScript + Tailwind + shadcn/ui already scaffolded, React Router for routes, Supabase JS client against Lovable Cloud.

## Database schema (create one migration)

Create four tables in the public schema:

1. `customers` — id (uuid pk references auth.users(id) on delete cascade), stripe_customer_id (text unique), email (text), default_payment_method (text), created_at (timestamptz default now()).

2. `payments` — id (uuid pk default gen_random_uuid()), user_id (uuid not null references auth.users(id)), stripe_payment_intent_id (text unique not null), amount_cents (int not null), currency (text not null default 'usd'), status (text not null default 'pending' check (status in ('pending','succeeded','failed','refunded'))), description (text), created_at (timestamptz default now()), updated_at (timestamptz default now()).

3. `subscriptions` — id (uuid pk default gen_random_uuid()), user_id (uuid not null references auth.users(id)), stripe_subscription_id (text unique not null), stripe_price_id (text), status (text not null check (status in ('trialing','active','past_due','canceled','incomplete','incomplete_expired'))), current_period_start (timestamptz), current_period_end (timestamptz), cancel_at_period_end (bool default false), created_at (timestamptz default now()).

4. `webhook_events` — id (text pk — this is the Stripe event.id, e.g. evt_xxx, providing natural idempotency), type (text not null), processed_at (timestamptz default now()), payload (jsonb).

Create a UNIQUE index on payments.stripe_payment_intent_id (already UNIQUE from the column definition, but be explicit).

Enable RLS on all four tables.
- customers: SELECT for auth.uid() = id; INSERT/UPDATE via service-role (webhook).
- payments: SELECT for auth.uid() = user_id; no user INSERT/UPDATE (webhook handles these).
- subscriptions: SELECT for auth.uid() = user_id; no user INSERT/UPDATE.
- webhook_events: SELECT for admin only via has_role('admin') function.

Create SECURITY DEFINER function `has_role(p_role text)` returns boolean: RETURN (auth.jwt() ->> 'app_metadata')::jsonb ->> 'role' = p_role;

## Layout and pages

Create or add to the existing AppLayout a set of payment-related routes:

- /pricing (Pricing.tsx) — show three pricing tier cards using shadcn Card component. Each card: tier name, price per month, 3-5 feature bullets, and a Buy button. The Buy button calls the create-checkout-session Edge Function with the tier's price_id and mode='subscription' (or 'payment' for one-time). Redirect to the returned checkout_url. If the user is not signed in, the Buy button routes to /login?redirect=/pricing instead. For now, hardcode three price_ids as constants at the top of the file.

- /payment-success (PaymentSuccess.tsx) — reads ?session_id= from the URL. Shows a loading spinner, then polls the payments table every 2 seconds (up to 30s) waiting for a row with status='succeeded' to appear (the webhook will write it). Once found, shows a checkmark animation (Framer Motion scale from 0 to 1 with spring), a receipt summary (amount, description), and a link to /account/billing. If 30s elapse without a row, show 'Your payment is being processed — check your email for a receipt.'

- /payment-canceled (PaymentCanceled.tsx) — simple page: 'You canceled checkout. No charge was made.' with a 'Try again' button linking back to /pricing.

- /account/billing (AccountBilling.tsx) — auth-gated. Shows: current subscription plan (query subscriptions table) with status badge, current_period_end date, cancel_at_period_end warning if true. List of recent payments (last 10, query payments table). A 'Manage billing' button that calls the customer-portal Edge Function and opens the returned URL in a new tab.

## Edge Function: ensure-stripe-customer

Create supabase/functions/ensure-stripe-customer/index.ts. The function accepts {user_id, email} in the body. It:
1. Checks customers.stripe_customer_id for this user_id — if it exists, return {stripe_customer_id} immediately.
2. If not, calls stripe.customers.create({email, metadata: {supabase_user_id: user_id}}).
3. Upserts into customers (id=user_id, stripe_customer_id, email) using the service-role key.
4. Returns {stripe_customer_id}.

## Edge Function: create-checkout-session

Create supabase/functions/create-checkout-session/index.ts. The function:
1. Reads the caller's user ID from the Authorization header (await supabase.auth.getUser()).
2. Calls ensure-stripe-customer (via supabase.functions.invoke) to get or create the stripe_customer_id.
3. Creates a Stripe Checkout Session: mode from request body ('payment' or 'subscription'), line_items with price_id, customer=stripe_customer_id, success_url='/payment-success?session_id={CHECKOUT_SESSION_ID}', cancel_url='/payment-canceled'.
4. Returns {checkout_url: session.url}.

## Edge Function: customer-portal

Create supabase/functions/customer-portal/index.ts. The function reads the caller's stripe_customer_id from the customers table, creates a Stripe BillingPortal Session with return_url='/account/billing', and returns {portal_url: session.url}.

## Edge Function: stripe-webhook

Create supabase/functions/stripe-webhook/index.ts. This is the most critical function — follow these rules exactly:

1. Read the raw body: `const rawBody = await req.text()` — DO NOT use `await req.json()` anywhere before signature verification.
2. Get the signature header: `const sig = req.headers.get('stripe-signature')`.
3. Call the async verification: `const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'))` — this MUST be `constructEventAsync` (async), NOT `constructEvent` (sync). Deno's SubtleCrypto is async; the sync version will throw 'SubtleCryptoProvider cannot be used in a synchronous context'.
4. Idempotency: INSERT into webhook_events (id=event.id, type=event.type, payload=event as JSON). If the insert fails with a UNIQUE constraint violation on id, return 200 immediately — this event was already processed.
5. Switch on event.type:
   - 'checkout.session.completed': create a payments row (user_id from session.metadata or customer lookup, stripe_payment_intent_id, amount_cents, status='succeeded') OR update subscription status.
   - 'invoice.paid': ensure subscription status is 'active'.
   - 'customer.subscription.updated': update subscriptions row (status, current_period_start, current_period_end, cancel_at_period_end).
   - 'customer.subscription.deleted': set subscriptions.status = 'canceled'.
6. Return 200 immediately after processing — Stripe retries on any non-200 or responses over 5 seconds.

## Key components

PricingCard (accepts tier name, price, features array, price_id, mode; renders shadcn Card with Buy button), PortalButton (calls customer-portal Edge Function on click, opens URL in new tab), PaymentStatus (polling component in /payment-success — polls payments table, shows spinner then success animation), ReceiptList (table of recent payments with amount, date, status badge).

## Styling

Light mode for /pricing (price legibility is better on white). Use shadcn Card for tier cards. Highlight the recommended tier with ring-2 ring-primary. Add a subtle Stripe badge ('Powered by Stripe') in the footer of /pricing. /payment-success uses a centered layout with large checkmark animation.

What this prompt generates

  • Creates one SQL migration with 4 tables (customers, payments, subscriptions, webhook_events), RLS policies, and UNIQUE indexes for idempotency
  • Generates /pricing with 3 tier cards and working Buy buttons calling create-checkout-session
  • Creates /payment-success with polling until webhook writes the payment row, /payment-canceled, and /account/billing with portal button
  • Builds all 4 Edge Functions: ensure-stripe-customer, create-checkout-session, customer-portal, and stripe-webhook with correct raw body + constructEventAsync
  • Creates PricingCard, PortalButton, PaymentStatus, and ReceiptList components

Paste into: Lovable Agent 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/0001_payments_schema.sql

4 tables + RLS + has_role() + UNIQUE indexes for idempotency

src/components/PricingCard.tsx

Tier card with price, features, and Buy button

src/components/PortalButton.tsx

Opens Stripe billing portal in new tab

src/components/PaymentStatus.tsx

Polls payments table, shows spinner then success animation

src/components/ReceiptList.tsx

Table of recent payments with status badges

src/pages/Pricing.tsx

Three tier cards with Buy buttons

src/pages/PaymentSuccess.tsx

Polls for payment row, shows checkmark on success

src/pages/PaymentCanceled.tsx

Graceful cancel with try-again CTA

src/pages/AccountBilling.tsx

Subscription status, recent payments, Stripe portal button

supabase/functions/ensure-stripe-customer/index.ts

Creates Stripe Customer on first checkout if not exists

supabase/functions/create-checkout-session/index.ts

Creates Stripe Checkout Session and returns URL

supabase/functions/customer-portal/index.ts

Creates Stripe Billing Portal session and returns URL

supabase/functions/stripe-webhook/index.ts

Raw body + constructEventAsync + idempotent event processing

Routes
/pricing

Three tier cards with working Stripe checkout

/payment-success

Polls for payment confirmation, shows checkmark

/payment-canceled

Graceful cancel page with try-again

/account/billing

Subscription status, payment history, portal button

DB Tables
customers
ColumnType
iduuid primary key references auth.users(id)
stripe_customer_idtext unique
emailtext
default_payment_methodtext

RLS: SELECT for auth.uid() = id; write via service-role webhook

payments
ColumnType
iduuid primary key
user_iduuid not null references auth.users(id)
stripe_payment_intent_idtext unique not null
amount_centsint not null
statustext check in (pending,succeeded,failed,refunded)

RLS: SELECT for user_id = auth.uid(); no user write

subscriptions
ColumnType
iduuid primary key
user_iduuid not null references auth.users(id)
stripe_subscription_idtext unique not null
statustext check in (trialing,active,past_due,canceled,incomplete,incomplete_expired)
cancel_at_period_endbool default false

RLS: SELECT for user_id = auth.uid(); no user write

webhook_events
ColumnType
idtext primary key
typetext not null
processed_attimestamptz default now()
payloadjsonb

RLS: Admin SELECT only via has_role('admin')

Components
PricingCard

Tier card with price, feature list, and Buy button that calls create-checkout-session

PortalButton

Calls customer-portal, opens returned URL in new tab

PaymentStatus

Polls payments table until succeeded row appears, then shows success animation

ReceiptList

Recent payments table with amount, date, status chip

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Add STRIPE_SECRET_KEY to Cloud Secrets the right way

Stripe secret key securely stored in Cloud Secrets — required before any Stripe Edge Function will work

~0 credits — just a careful click
prompt
Before running any prompt that references Stripe, add your Stripe secret key to Cloud Secrets securely.

In the Lovable chat input, look for the API Key icon (lock icon with a key, to the left of the message input). Click it. A secure form appears with 'Key name' and 'Key value' fields. Enter STRIPE_SECRET_KEY as the name and paste your Stripe secret key (sk_test_... for testing, sk_live_... for production) as the value. Click Save.

Alternatively, go directly to Cloud tab → Secrets → New Secret.

NEVER paste the secret key directly into a chat message. Chat messages are logged by Lovable for quality and debugging purposes, which means a pasted secret could be recovered later. The API Key icon form writes directly to Secrets without logging the value.

Repeat this process for STRIPE_WEBHOOK_SECRET once you set up the webhook endpoint in Stripe Dashboard. The test webhook secret and live webhook secret are different — update this secret when you switch from test to live mode.

When to use: Immediately, before running any other follow-up prompt in this kit

2

Build create-checkout-session with ensure-stripe-customer

Working Stripe checkout that creates or reuses a Stripe Customer and generates a hosted Checkout URL

~25-35 credits
prompt
Implement the create-checkout-session Edge Function at supabase/functions/create-checkout-session/index.ts.

The function should:
1. Parse the request body for {price_id, mode} where mode is 'payment' or 'subscription'.
2. Get the caller's user ID: const { data: { user } } = await supabase.auth.getUser(authHeader).
3. If no user, return 401 {error: 'Not authenticated'}.
4. Call ensure-stripe-customer: const { data: { stripe_customer_id } } = await supabase.functions.invoke('ensure-stripe-customer', { body: { user_id: user.id, email: user.email } }).
5. Create a Stripe Checkout Session: await stripe.checkout.sessions.create({ customer: stripe_customer_id, mode, line_items: [{price: price_id, quantity: 1}], success_url: `${origin}/payment-success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/payment-canceled`, metadata: { user_id: user.id } }).
6. Return {checkout_url: session.url}.

In Pricing.tsx, update each PricingCard's Buy button onClick to:
const { data } = await supabase.functions.invoke('create-checkout-session', { body: { price_id: tier.price_id, mode: tier.mode } });
window.location.href = data.checkout_url;

Show a loading spinner on the button while the request is in flight.

When to use: After STRIPE_SECRET_KEY is in Cloud Secrets

3

Build stripe-webhook with raw body + async verification + idempotency

Stripe webhook with the two mandatory Deno fixes (raw body + async verify) and idempotency via webhook_events table

~40-55 credits
prompt
Implement the stripe-webhook Edge Function at supabase/functions/stripe-webhook/index.ts. This is the most error-prone part of any Stripe integration — follow these steps exactly.

The function:
1. `const rawBody = await req.text()` — this is the raw bytes Stripe signed. Never replace this with req.json().
2. `const sig = req.headers.get('stripe-signature')` — this is the signature header Stripe sends.
3. `const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'))` — async version only. If you see 'SubtleCryptoProvider cannot be used in a synchronous context', you used constructEvent instead of constructEventAsync.
4. Idempotency check: try { await supabase.from('webhook_events').insert({id: event.id, type: event.type, payload: event}) } catch (e) { if (e.code === '23505') return new Response('Already processed', {status: 200}); throw e; }
5. Switch on event.type:
   - 'checkout.session.completed': look up user_id from event.data.object.metadata.user_id. INSERT into payments: {user_id, stripe_payment_intent_id: event.data.object.payment_intent, amount_cents: event.data.object.amount_total, status: 'succeeded'}. If mode='subscription', INSERT into subscriptions with event.data.object.subscription.
   - 'invoice.paid': UPDATE subscriptions SET status='active' WHERE stripe_subscription_id = event.data.object.subscription.
   - 'customer.subscription.updated': UPDATE subscriptions SET status, current_period_start, current_period_end, cancel_at_period_end WHERE stripe_subscription_id = event.data.object.id.
   - 'customer.subscription.deleted': UPDATE subscriptions SET status='canceled'.
6. return new Response('OK', {status: 200}) — return 200 fast. Stripe retries any non-200.

Use the Supabase client with the service-role key (Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')) for all database writes in this function — the webhook request has no user auth header.

When to use: After create-checkout-session works; then register the webhook endpoint in Stripe Dashboard

4

Register the webhook endpoint and test end-to-end

Stripe webhook endpoint registration and a complete end-to-end test flow

~10 credits — mostly Stripe Dashboard work and test card verification
prompt
Before the webhook can receive events, register it in Stripe Dashboard.

For testing without deploying:
1. In Stripe Dashboard → Developers → Webhooks → Add endpoint.
2. Endpoint URL: your Lovable preview URL + /api/stripe-webhook (e.g. https://your-project.lovable.app/api/stripe-webhook). Lovable preview URLs are HTTPS and publicly accessible — webhook callbacks work.
3. Select events to listen to: checkout.session.completed, invoice.paid, customer.subscription.updated, customer.subscription.deleted.
4. Click Add endpoint. Copy the Signing secret (whsec_...) that appears — this is your STRIPE_WEBHOOK_SECRET for the test endpoint.
5. Add STRIPE_WEBHOOK_SECRET to Cloud tab → Secrets.

For production: after deploying to a custom domain, create a SEPARATE webhook endpoint pointing at your production URL with its own signing secret. Update STRIPE_WEBHOOK_SECRET to the production value.

Test the flow:
1. Go to /pricing and click a Buy button.
2. Use Stripe test card: 4242 4242 4242 4242, expiry 12/34, CVC 123, any ZIP.
3. Complete checkout — Stripe redirects to /payment-success.
4. In Stripe Dashboard → Events, confirm checkout.session.completed fired.
5. In Cloud tab → Database → SQL Editor: SELECT * FROM payments ORDER BY created_at DESC LIMIT 1 — should show a succeeded row.
6. In /account/billing, confirm the subscription or payment appears.

When to use: Immediately after stripe-webhook is deployed

5

Add customer portal for self-serve billing management

Self-serve billing portal so users can manage their subscription without contacting support

~18-25 credits
prompt
Implement the customer-portal Edge Function at supabase/functions/customer-portal/index.ts.

The function:
1. Gets the caller's user ID from the Authorization header.
2. Queries customers.stripe_customer_id for that user. If null, calls ensure-stripe-customer first.
3. Creates a Stripe Billing Portal Session: await stripe.billingPortal.sessions.create({ customer: stripe_customer_id, return_url: `${origin}/account/billing` }).
4. Returns {portal_url: session.url}.

In AccountBilling.tsx, the PortalButton onClick:
const { data } = await supabase.functions.invoke('customer-portal');
window.open(data.portal_url, '_blank');

The Stripe portal lets users: view invoices, update payment method, cancel subscription, download receipts. Zero custom billing UI needed for these tasks.

Note: Stripe Billing Portal must be enabled in Stripe Dashboard → Settings → Billing → Customer portal. Toggle it on and configure what actions you want to allow (cancellation, plan changes, payment method updates).

When to use: After stripe-webhook works — this is zero-touch billing support

6

Switch to live mode

Live Stripe mode configured with separate production webhook endpoint and signing secret

~10 credits — mostly Stripe Dashboard configuration
prompt
When you're ready to accept real money, switch from Stripe test mode to live mode.

1. In Stripe Dashboard, complete account activation: Dashboard → Settings → Account details → provide your legal business name, address, and bank account for payouts.

2. In Stripe Dashboard, switch to Live mode (toggle top-left of Dashboard).

3. Copy your live secret key (sk_live_...) from Dashboard → Developers → API keys.

4. In Lovable: Cloud tab → Secrets → STRIPE_SECRET_KEY → update to sk_live_... value.

5. In Stripe Dashboard (Live mode) → Developers → Webhooks → Add endpoint. Add a NEW endpoint for your production URL. Copy the new Signing secret. Update STRIPE_WEBHOOK_SECRET in Cloud Secrets to this live signing secret. The test endpoint and live endpoint have DIFFERENT signing secrets — mixing them up is the #1 go-live mistake.

6. Test with your own real card: go to /pricing, buy the cheapest plan, verify the payment appears in the Stripe Dashboard and in your payments table. Then immediately go to Stripe Dashboard → Payments → find your charge → Refund.

7. Remove any hardcoded test card numbers or price_ids from the codebase. Update price_ids in Pricing.tsx to live-mode Price IDs (they start with price_live_xxx in Live mode).

When to use: When you've validated the full test flow and are ready to charge real users

7

Add Resend custom branded receipts

Custom-branded receipt emails sent via Resend after checkout.session.completed, with error handling that never fails the webhook

~25-30 credits
prompt
Add custom-branded receipt emails after payment. In the stripe-webhook Edge Function, after processing a 'checkout.session.completed' event and inserting the payments row, add a Resend email call.

Get the user's email from auth.users using the service-role Supabase client: const { data: user } = await supabase.auth.admin.getUserById(user_id).

Call Resend: import { Resend } from 'https://esm.sh/resend@latest'; const resend = new Resend(Deno.env.get('RESEND_API_KEY')); await resend.emails.send({ from: 'receipts@your-domain.com', to: user.email, subject: 'Your receipt from [Your App]', html: `<h2>Payment confirmed</h2><p>Amount: $${(payment.amount_cents / 100).toFixed(2)} ${payment.currency.toUpperCase()}</p><p>View your billing details: <a href='https://your-app.com/account/billing'>Account Billing</a></p>` });

Wrap the email send in try/catch — a failed email must never fail the webhook response. Log the error but still return 200.

Note: Stripe sends its own email receipts by default (toggle in Stripe Dashboard → Settings → Customer emails). Decide once whether you want Stripe's receipts or your own Resend receipts, and disable the other to avoid sending both to the user.

Add RESEND_API_KEY to Cloud Secrets before running this prompt.

When to use: After the full flow works in live mode and default Stripe receipts feel off-brand

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

SubtleCryptoProvider cannot be used in a synchronous context. Use await stripe.webhooks.constructEventAsync(...) instead

You called stripe.webhooks.constructEvent() (the sync version). Deno's SubtleCrypto API is async — the sync version physically cannot work in a Deno Edge Function context.

Fix — paste into Lovable Agent Mode
In stripe-webhook/index.ts, replace stripe.webhooks.constructEvent(rawBody, sig, secret) with await stripe.webhooks.constructEventAsync(rawBody, sig, secret). Make sure the enclosing handler function is declared async. This exact error and fix is documented in a published Lovable build walkthrough.
Webhook signature verification failed

You read the request body with await req.json() before passing it to constructEventAsync. JSON parsing re-serializes the bytes and changes them, invalidating Stripe's signature which was computed against the original raw bytes.

Fix — paste into Lovable Agent Mode
In stripe-webhook/index.ts, change const body = await req.json() to const rawBody = await req.text(). Pass rawBody to constructEventAsync. After successful verification, parse the event from rawBody only if needed: const parsed = JSON.parse(rawBody). The Stripe SDK's constructEventAsync returns the parsed event object, so you usually don't need to parse rawBody separately.
Stripe checkout works in preview but webhook never fires

Stripe webhooks require a publicly accessible HTTPS URL. If you're using the Lovable preview URL, it must be registered in Stripe Dashboard as a webhook endpoint — Stripe doesn't auto-discover it.

Fix — paste into Lovable Agent Mode
In Stripe Dashboard → Developers → Webhooks → Add endpoint, paste your Lovable preview URL (e.g. https://your-project.lovable.app/api/stripe-webhook). The preview URL is HTTPS and publicly accessible — Stripe can reach it. For production, create a SEPARATE endpoint with your production custom domain URL. Each endpoint has its own signing secret — make sure STRIPE_WEBHOOK_SECRET matches the endpoint you're testing.
Duplicate payment rows after a Stripe retry

Stripe retries webhooks if you don't return 200 within 5 seconds or if there's a network error. Without idempotency on webhook_events.id, the same checkout.session.completed event can insert two payments rows.

Fix — paste into Lovable Agent Mode
At the start of stripe-webhook processing, INSERT into webhook_events(id, type, payload) where id = event.id (Stripe's event ID). If the INSERT raises a UNIQUE constraint violation (PostgreSQL error code 23505), catch it and return 200 immediately — the event was already processed. Only continue to business logic if the insert succeeded.
Invalid API Key provided / 401 from Stripe

You either used the publishable key (pk_...) where the secret key was expected, the secret is from the wrong mode (test vs live), or there is trailing whitespace in Cloud Secrets.

Fix — paste into Lovable Agent Mode
Open Cloud tab → Secrets → STRIPE_SECRET_KEY. Confirm it starts with sk_test_ (test mode) or sk_live_ (live mode), not pk_ (publishable key is for the browser, not server). Check for any trailing space or newline — delete and re-paste the value. If you recently switched from test to live, confirm you updated the secret to the live key.
No such customer: cus_xxx when calling customer-portal

The user signed up before ensure-stripe-customer was set up, or the trigger failed silently for that user. Their profiles row has a null stripe_customer_id.

Fix — paste into Lovable Agent Mode
In customer-portal Edge Function, before creating the portal session, check if customers.stripe_customer_id is null. If so, call ensure-stripe-customer inline: const result = await supabase.functions.invoke('ensure-stripe-customer', {body: {user_id, email}}). Use the freshly returned stripe_customer_id for the portal session. This makes customer-portal idempotent regardless of whether ensure-stripe-customer ran at signup.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo. The tomokat walkthrough documented a full Stripe flow — checkout, Edge Functions, webhook with constructEventAsync fix — in under 10 credits in one session. Most builders take 80-150 credits due to iteration on the webhook async/raw-body issues. Pro's 100 credits should cover one complete build with the webhook fixed on the first try.

Monthly run cost breakdown

~80-150 credits (starter ~30-50, seven follow-ups ~100-140 if done in full; most readers do 4 follow-ups for ~80 credits total). The tomokat sub-10-credit data point is the floor — it assumes you get webhook right on first try, which most builders don't. total
ItemCost
Lovable Pro

Can drop to Free after launch if not iterating

$25/mo
Lovable Cloud / Supabase Free

Payments table is tiny — stays within free tier indefinitely

$0/mo
Stripe

This is the actual cost driver — scales with revenue, not user count

2.9% + 30¢ per successful charge (US)
Stripe Billing add-on

Only if using advanced Billing features like metered billing or quotes

0.5-0.8%
Resend

Free up to 3,000 emails/month for custom receipts

$0/mo
Custom domain$10-15/yr

Scaling notes: There is no infrastructure scaling cost for this integration — Stripe takes their percentage and that is the only line item that scales. Switch to Stripe's volume pricing (negotiated, typically at $100K+ monthly volume) when you grow. If global sales tax compliance becomes painful, consider switching to Paddle ($5% + 50¢ per transaction, Merchant of Record — handles VAT/sales tax globally so you don't have to).

Production checklist

Steps to take before you share the URL with real users.

Secret Key Security

  • Audit your Lovable chat history for any accidentally pasted Stripe keys

    In Lovable, open the chat history and search for 'sk_test' or 'sk_live'. If you find a pasted secret key, immediately go to Stripe Dashboard → Developers → API keys → Roll the secret key (generates a new one and invalidates the old). Update Cloud Secrets with the new key.

  • Confirm publishable key vs secret key placement

    The publishable key (pk_test_... or pk_live_...) goes on the frontend — in your React code or as a VITE_ prefixed env var. The secret key (sk_test_... or sk_live_...) goes only in Cloud Secrets and is only accessible inside Edge Functions. If you see the secret key in any src/ file, remove it immediately and roll the key.

Webhook Testing

  • Verify webhook idempotency handles retries

    In Stripe Dashboard → Developers → Webhooks → your endpoint → Resend an existing event. The webhook should return 200 immediately (already processed) without creating a duplicate payment row. Check the webhook_events table to confirm only one row exists per event.id.

  • Test the full checkout flow as a real user

    Sign in as a test user (not your admin), go to /pricing, click Buy, complete checkout with test card 4242 4242 4242 4242 expiry 12/34 CVC 123. Verify: /payment-success shows the checkmark within 30 seconds; /account/billing shows the subscription or payment; Stripe Dashboard shows the payment in the Events list.

Live Mode

  • Create separate live webhook endpoint with its own signing secret

    In Stripe Dashboard (Live mode) → Developers → Webhooks → Add endpoint. Point it at your production custom domain URL. Copy the new whsec_live_... signing secret. Update STRIPE_WEBHOOK_SECRET in Cloud Secrets. The test and live signing secrets are different — if you use the test secret for live events, every webhook returns 400.

Email Receipts

  • Choose Stripe receipts OR Resend receipts — not both

    In Stripe Dashboard → Settings → Customer emails: toggle 'Successful payments' and 'Subscription renewals' ON or OFF. If ON, Stripe sends its own receipt. If you also have Resend receipts in stripe-webhook, users get duplicate emails. Pick one and disable the other.

Frequently asked questions

Can I test Stripe in the Lovable preview without deploying?

Yes — Lovable preview URLs are publicly accessible HTTPS URLs, which means Stripe can send webhook callbacks to them. Register your preview URL as a webhook endpoint in Stripe Dashboard (Developers → Webhooks → Add endpoint), select the events you want, and copy the signing secret to Cloud Secrets. You can complete a full test checkout cycle (including webhook processing) without deploying to a custom domain. Just remember to create a separate webhook endpoint when you deploy to production — preview and production have different URLs and need different signing secrets.

Why does my webhook fail with 'Webhook signature verification failed'?

Almost always because you read the request body with await req.json() instead of await req.text(). JSON parsing re-serializes the bytes in potentially different order or encoding than the original request body that Stripe signed. The constructEventAsync function needs the exact raw bytes as Stripe sent them. Fix: change every occurrence of await req.json() to await req.text() in stripe-webhook/index.ts, and only parse the event data from the already-verified event object that constructEventAsync returns.

Should I use Stripe, Paddle, or Lemon Squeezy?

Use Stripe if you're primarily selling in the US or UK, want full control over your pricing and customer data, and can handle sales tax compliance yourself (or you're below the registration thresholds in most jurisdictions). Use Paddle or Lemon Squeezy if you sell globally and want zero tax headaches in exchange for a ~2-3% higher fee — both are Merchant of Record services that collect and remit VAT and sales tax on your behalf, which removes a significant compliance burden. Lemon Squeezy is friendlier for solopreneurs and simple digital goods; Paddle handles more complex scenarios like B2B invoicing and seat-based pricing. This prompt kit is built around Stripe but the same constructEventAsync + raw-body webhook pattern applies to Paddle's webhooks.

How do I handle sales tax and VAT?

With Stripe, you are responsible for calculating, collecting, and remitting sales tax (US) and VAT (EU/UK). Stripe Tax ($0.50/transaction where enabled) can calculate tax automatically and generate reports, but you still file and remit yourself. If this sounds painful, switch to Paddle or Lemon Squeezy (Merchant of Record) — they handle all tax compliance globally for ~5% + 50¢ per transaction. For most early-stage founders under $100K revenue, you're likely below the registration thresholds in most US states and EU member states — consult a tax professional about your specific situation.

What if my Stripe secret key leaks?

Act immediately: go to Stripe Dashboard → Developers → API keys → Roll the secret key. This generates a new secret key and invalidates the old one within minutes — any requests using the old key will fail. Update STRIPE_SECRET_KEY in Cloud Secrets with the new value. If the leaked key was live mode, also review your Stripe Dashboard → Events log for any suspicious charges made in the window between the leak and the roll. Report unauthorized charges to Stripe's fraud team.

Can I switch from test mode to live mode without rebuilding?

Yes — it's a configuration change, not a rebuild. Update STRIPE_SECRET_KEY in Cloud Secrets from sk_test_... to sk_live_... Create a new webhook endpoint in Stripe Dashboard (Live mode) pointing at your production URL, and update STRIPE_WEBHOOK_SECRET to the new signing secret. Update the price_ids in Pricing.tsx to live-mode Price IDs. Test with a real $1 charge on your own card and refund immediately. The code and database schema are identical between test and live modes — only the keys and price IDs change.

Can RapidDev set up Stripe Connect for marketplace payouts?

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 consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.