TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Build mode and get a working single-seller storefront: product catalog, persistent cart (DB-backed for signed-in users, localStorage for guests), Stripe Checkout, and a webhook that creates orders and decrements stock atomically. The raw-body webhook pattern and stock-decrement timing are the two non-negotiable pieces. Full chain: ~100–180 credits on Pro $25/mo, about one full day.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores products, variants, carts, cart_items, orders, and order_items. The starter prompt creates all six tables plus the decrement_stock() RPC function.
- 1Click the + button next to Preview to open the Cloud panel
- 2Click Database — leave the Table Editor empty; the starter prompt generates the migration
- 3After the migration runs, verify the decrement_stock function appears in Cloud → Database → Functions
Auth
Email/password for signed-in customers and the admin. Guest checkout works without an account — auth is optional.
- 1Cloud tab → Users & Auth → confirm Email sign-in is enabled
- 2Leave 'Allow new users to sign up' ON — customers self-register
- 3After building, create your admin user via Cloud → Users & Auth → Add user, then set app_metadata to {"role": "admin"}
Storage
Stores product images uploaded by the admin. One public bucket so images render on the public catalog.
- 1Cloud tab → Storage → Create bucket 'product-images', toggle Public ON
- 2The starter prompt references 'product-images' throughout — do not rename it
Edge Functions
create-checkout-session creates the Stripe Checkout Session server-side (Stripe secret key never touches the browser). stripe-webhook handles the payment confirmation with the mandatory raw-body pattern.
- 1Cloud tab → Edge Functions — leave empty; functions deploy from supabase/functions/ automatically
- 2After the starter prompt runs, verify create-checkout-session and stripe-webhook appear here
- 3After deploying your app, register the webhook URL in the Stripe dashboard: https://your-domain.com/functions/v1/stripe-webhook
Secrets (Cloud tab → Secrets)
STRIPE_SECRET_KEYPurpose: Creates Stripe Checkout Sessions in create-checkout-session edge function. Never expose in client-side code or VITE_ env vars.
Where to get it: https://dashboard.stripe.com/apikeys — copy the secret key starting with sk_test_ (for testing) or sk_live_ (for production). Start with test key.
STRIPE_WEBHOOK_SECRETPurpose: Verifies Stripe webhook signatures via constructEventAsync to prevent spoofed webhook calls
Where to get it: https://dashboard.stripe.com/webhooks — create a new webhook endpoint, set URL to https://your-deployed-domain/functions/v1/stripe-webhook, check checkout.session.completed and payment_intent.succeeded events, copy the signing secret starting with whsec_
RESEND_API_KEYPurpose: Sends order confirmation emails with line items and shipping address after successful payment
Where to get it: https://resend.com/api-keys — free account, Create API Key with Sending access, copy key starting with re_
SITE_URLPurpose: Used as the success_url and cancel_url in Stripe Checkout Sessions — must be a publicly reachable URL, not the Lovable preview
Where to get it: After publishing (top-right Publish button), copy the production URL or custom domain
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
- You're on Pro $25/mo — Stripe webhook iteration alone uses 30+ credits; Free's daily cap blocks you mid-debug
- You have a Stripe account at stripe.com — test mode is fine during build (no real money)
- Critical: Stripe MUST be tested on a deployed URL. The Lovable preview iframe cannot receive Stripe redirects or webhooks — Publish the project before any Stripe testing
- Stock is decremented ONLY in the Stripe webhook, not at add-to-cart and not at checkout redirect — this is the most-skipped pattern and it matters
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.
Build a single-seller e-commerce storefront. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6, Supabase JS client against Lovable Cloud.
## Database schema (migration: supabase/migrations/0001_shop_schema.sql)
Create six tables:
1. `products` — id (uuid pk default gen_random_uuid()), slug (text unique not null), name (text not null), description (text), price_cents (int not null check (price_cents > 0)), stock (int not null default 0 check (stock >= 0)), image_path (text), archived (bool default false), created_at (timestamptz default now()).
RLS: enable. FOR SELECT TO anon, authenticated USING (archived = false). FOR INSERT/UPDATE/DELETE TO authenticated USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin').
2. `variants` — id (uuid pk default gen_random_uuid()), product_id (uuid references products.id on delete cascade not null), name (text not null), price_cents (int nullable), stock (int not null default 0 check (stock >= 0)), created_at (timestamptz default now()).
RLS: enable. FOR SELECT TO anon, authenticated USING (EXISTS (SELECT 1 FROM products p WHERE p.id = variants.product_id AND p.archived = false)). FOR INSERT/UPDATE/DELETE TO authenticated USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin').
3. `carts` — id (uuid pk default gen_random_uuid()), user_id (uuid references auth.users.id on delete cascade not null unique), created_at (timestamptz default now()), updated_at (timestamptz default now()).
RLS: enable. All operations: USING (user_id = auth.uid()) / WITH CHECK (user_id = auth.uid()).
4. `cart_items` — id (uuid pk default gen_random_uuid()), cart_id (uuid references carts.id on delete cascade not null), product_id (uuid references products.id not null), variant_id (uuid references variants.id nullable), qty (int not null check (qty > 0)), UNIQUE (cart_id, product_id, variant_id).
RLS: enable. All operations: USING (EXISTS (SELECT 1 FROM carts c WHERE c.id = cart_items.cart_id AND c.user_id = auth.uid())).
5. `orders` — id (uuid pk default gen_random_uuid()), customer_email (text not null), customer_user_id (uuid references auth.users.id nullable), stripe_session_id (text unique not null), stripe_payment_intent_id (text unique), subtotal_cents (int not null), tax_cents (int not null default 0), total_cents (int not null), status (text not null default 'pending' check (status in ('pending','paid','fulfilled','refunded','cancelled'))), shipping_address (jsonb), placed_at (timestamptz default now()).
RLS: enable. FOR SELECT TO authenticated USING (customer_user_id = auth.uid()). FOR SELECT admin: USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'). No INSERT for clients — created by webhook only.
6. `order_items` — id (uuid pk default gen_random_uuid()), order_id (uuid references orders.id not null), product_id (uuid references products.id not null), variant_id (uuid references variants.id nullable), qty (int not null), unit_price_cents (int not null), product_name_snapshot (text not null).
RLS: enable. FOR SELECT: inherits via order — USING (EXISTS (SELECT 1 FROM orders o WHERE o.id = order_items.order_id AND (o.customer_user_id = auth.uid() OR (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'))).
Create a stock-decrement RPC function:
CREATE OR REPLACE FUNCTION decrement_stock(p_product uuid, p_variant uuid, p_qty int)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF p_variant IS NOT NULL THEN
UPDATE variants SET stock = stock - p_qty WHERE id = p_variant;
IF NOT FOUND OR (SELECT stock FROM variants WHERE id = p_variant) < 0 THEN
RAISE EXCEPTION 'insufficient_stock: variant %', p_variant;
END IF;
ELSE
UPDATE products SET stock = stock - p_qty WHERE id = p_product;
IF NOT FOUND OR (SELECT stock FROM products WHERE id = p_product) < 0 THEN
RAISE EXCEPTION 'insufficient_stock: product %', p_product;
END IF;
END IF;
END;
$$;
## Layouts
`src/layouts/PublicLayout.tsx` — top nav: Logo / Shop link / Cart icon (from lucide-react) with count badge / Sign in button. Cart icon opens CartDrawer (Sheet from right). Footer with copyright.
`src/layouts/AdminLayout.tsx` — sidebar: Products / Orders / Settings. Protected by AdminGuard that checks app_metadata.role === 'admin'.
## Pages (add to src/App.tsx)
- `/` — Catalog.tsx: product grid (3-col desktop, 1-col mobile), each card shows image (from Storage public URL), name, price. Use shadcn Card. Empty state when no products.
- `/product/[slug]` — ProductDetail.tsx: large image, name, description, price, stock count. If product has variants, show VariantSelector. Add to Cart button: signed-in → insert/update cart_items in DB; guest → write to localStorage as JSON array [{product_id, variant_id, qty}]. Show CartDrawer on success.
- `/cart` — Cart.tsx: reads from DB (signed-in) or localStorage (guest). CartItemRow for each line item with qty stepper and remove button. Order subtotal. 'Checkout' button → POSTs to create-checkout-session edge function.
- `/checkout` — Checkout.tsx: simple redirect wrapper — on mount it calls create-checkout-session and redirects to Stripe Checkout URL. Shows loading spinner.
- `/success` — Success.tsx: reads session_id from URL query param, shows 'Order confirmed!' message and links to /orders.
- `/orders` — Orders.tsx: signed-in only (redirect to /signin if not). Table of past orders with status badges.
- `/signin` and `/signup` — basic email+password forms. On successful sign-in, call the cart-merge logic (see useCart hook).
- `/admin/products` — admin/Products.tsx: table of all products with edit Sheet (name, price, stock, image upload, archived toggle). Add Product button.
- `/admin/orders` — admin/Orders.tsx: table of all orders with status filter chips and update button.
## Key components and hooks
`src/hooks/useCart.ts` — manages dual-mode cart:
- If signed in: read/write cart_items from DB via Supabase JS. Use supabase.from('carts').select('*, cart_items(*, products(*), variants(*))') joined.
- If guest: read/write from localStorage key 'cart_items' as JSON.
- Export: { items, addItem, updateQty, removeItem, clearCart, mergeGuestCart, cartCount, subtotalCents }.
- mergeGuestCart(): called after sign-in. Reads localStorage, upserts each item into the DB cart using INSERT INTO cart_items ON CONFLICT (cart_id, product_id, variant_id) DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty, then clears localStorage.
`src/components/CartDrawer.tsx` — Sheet from right. Shows cart items from useCart, qty steppers, subtotal, 'Go to Cart' button.
`src/components/CartItemRow.tsx` — image thumbnail, name + variant, unit price, qty stepper, remove button.
`src/components/ProductCard.tsx` — product image, name, price. Click → /product/[slug].
`src/components/VariantSelector.tsx` — renders variant chips (e.g. Size: S / M / L). Updates selected variant state.
`src/components/AdminGuard.tsx` — reads JWT app_metadata.role, redirects to /unauthorized if not admin.
## Edge functions
`supabase/functions/create-checkout-session/index.ts` — POST JSON { cart_items: [{product_id, variant_id, qty, name, unit_price_cents}], customer_email, shipping_address, customer_user_id (nullable) }. Steps:
1. CORS preflight.
2. Use service-role Supabase client.
3. Validate each cart_item: fetch product (and variant if set), check archived=false and stock > 0.
4. Create Stripe Checkout Session: mode: 'payment', line_items from cart, automatic_payment_methods: { enabled: true } (do NOT set payment_method_types — these conflict in 2024+ Stripe API), success_url: SITE_URL + '/success?session_id={CHECKOUT_SESSION_ID}', cancel_url: SITE_URL + '/cart', metadata: { cart_items: JSON.stringify(cart_items), customer_user_id: customer_user_id ?? '', customer_email }.
5. Return { url: session.url } to client which immediately redirects.
`supabase/functions/stripe-webhook/index.ts` — CRITICAL: (1) const rawBody = await req.text(); MUST be line 1. (2) CORS preflight. (3) const sig = req.headers.get('stripe-signature'); (4) const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')); — use constructEventAsync, NOT constructEvent. (5) On event.type === 'checkout.session.completed': parse metadata from event.data.object.metadata. (6) INSERT INTO orders (..., stripe_session_id) ON CONFLICT (stripe_session_id) DO NOTHING — idempotent. (7) For each cart_item in metadata: INSERT INTO order_items (..., product_name_snapshot). (8) Call decrement_stock RPC for each line item: supabase.rpc('decrement_stock', { p_product: product_id, p_variant: variant_id, p_qty: qty }). On exception ('insufficient_stock'), call stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) and update order.status='refunded'. (9) Send confirmation email via Resend. (10) Return 200.
## Styling
Clean commerce: white background, slate text, single indigo-600 accent. Product grid: rounded-xl cards with shadow-sm on hover. Cart drawer: clean list, line items left-aligned with price right-aligned. Admin: table-heavy, desktop-first. Use shadcn Card, Sheet, Dialog, Badge, Toast, Skeleton, Table.
Generate migration (with decrement_stock function) first, then layouts, then pages in order, then edge functions.What this prompt generates
- Creates a SQL migration with 6 tables, admin-only RLS policies, and a decrement_stock() RPC function that raises an exception on insufficient stock
- Scaffolds PublicLayout with cart drawer and AdminLayout with AdminGuard
- Generates 9 public pages (catalog, product detail, cart, checkout, success, orders, signin, signup) and 2 admin pages (products, orders)
- Creates useCart hook with dual DB/localStorage backing and mergeGuestCart function for cart merge on sign-in
- Creates create-checkout-session edge function and stripe-webhook edge function with raw-body pattern and constructEventAsync
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_shop_schema.sql6 tables + RLS + decrement_stock() RPC
src/layouts/PublicLayout.tsxTop nav with cart icon + count badge + CartDrawer
src/layouts/AdminLayout.tsxSidebar layout with AdminGuard
src/components/AdminGuard.tsxJWT app_metadata.role check, redirects non-admins
src/hooks/useCart.tsDual DB/localStorage cart with merge on sign-in
src/pages/Catalog.tsxProduct grid with empty state
src/pages/ProductDetail.tsxProduct detail with VariantSelector and add-to-cart
src/pages/Cart.tsxCart review with CartItemRow components
src/pages/Checkout.tsxCalls edge function, redirects to Stripe Checkout URL
src/pages/Success.tsxOrder confirmed page with order link
src/pages/Orders.tsxSigned-in customer's order history
src/pages/admin/Products.tsxAdmin product CRUD with image upload
src/pages/admin/Orders.tsxAdmin order table with status filters
src/components/CartDrawer.tsxSheet drawer with cart items and checkout CTA
src/components/VariantSelector.tsxVariant chip selector (size, color etc)
src/components/ProductCard.tsxCatalog card with image, name, price
supabase/functions/create-checkout-session/index.tsStripe Checkout Session creation with line_items
supabase/functions/stripe-webhook/index.tsRaw-body webhook: order insert + stock decrement + refund on oversell
Routes
Public product catalog grid
Product detail with variant selector and add to cart
Cart review — DB or localStorage depending on auth
Redirect to Stripe Checkout
Order confirmed, links to order history
Signed-in customer order history
Customer email+password sign-in with cart merge
Admin: product CRUD with image upload
Admin: order table with status management
DB Tables
products| Column | Type |
|---|---|
| id | uuid primary key |
| slug | text unique not null |
| name | text not null |
| price_cents | int not null |
| stock | int not null default 0 |
| image_path | text nullable |
| archived | bool default false |
RLS: Public read where archived=false. Admin write only via app_metadata.role='admin'.
orders| Column | Type |
|---|---|
| id | uuid primary key |
| customer_email | text not null |
| customer_user_id | uuid nullable references auth.users |
| stripe_session_id | text unique not null |
| total_cents | int not null |
| status | text check in ('pending','paid','fulfilled','refunded','cancelled') |
| shipping_address | jsonb |
RLS: Customer reads own (customer_user_id = auth.uid()). Admin reads all. No client INSERT — webhook only. Unique on stripe_session_id for idempotency.
cart_items| Column | Type |
|---|---|
| id | uuid primary key |
| cart_id | uuid references carts |
| product_id | uuid references products |
| variant_id | uuid nullable references variants |
| qty | int not null check (qty > 0) |
RLS: Per-cart access via inner join to carts.user_id = auth.uid(). Unique on (cart_id, product_id, variant_id).
order_items| Column | Type |
|---|---|
| id | uuid primary key |
| order_id | uuid references orders |
| product_id | uuid references products |
| qty | int not null |
| unit_price_cents | int not null |
| product_name_snapshot | text not null |
RLS: Inherits via order. Customer and admin read own orders' items.
carts| Column | Type |
|---|---|
| id | uuid primary key |
| user_id | uuid unique references auth.users |
RLS: Per-user — all operations USING (user_id = auth.uid()).
variants| Column | Type |
|---|---|
| id | uuid primary key |
| product_id | uuid references products |
| name | text not null |
| price_cents | int nullable |
| stock | int not null default 0 |
RLS: Public read via parent product visibility. Admin write only.
Components
CartDrawerSheet drawer from right with cart items, subtotal, and checkout CTA
ProductCardCatalog card showing image, name, and price
VariantSelectorChip selector for product variants (size, color, etc)
CartItemRowLine item with image, name, qty stepper, remove button
AdminGuardChecks JWT app_metadata.role='admin', redirects otherwise
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Harden the Stripe webhook: raw body + constructEventAsync + idempotency
Correct raw-body webhook pattern, constructEventAsync, idempotent order insert, and automatic Stripe refund on stock exhaustion
Update supabase/functions/stripe-webhook/index.ts with the mandatory raw-body pattern:
The VERY FIRST line of the handler must be:
const rawBody = await req.text();
DO NOT call req.json() or JSON.parse before constructEventAsync. The bytes Stripe signed are the raw body bytes.
Then:
const sig = req.headers.get('stripe-signature');
const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'));
Note: constructEventAsync (async), NOT constructEvent (sync). Deno's WebCrypto is async-only — the sync version throws 'SubtleCryptoProvider cannot be used in a synchronous context'.
For idempotency: INSERT INTO orders (..., stripe_session_id) ... ON CONFLICT (stripe_session_id) DO NOTHING. If the conflict fires, still return 200 so Stripe stops retrying.
For the stock decrement: call the decrement_stock RPC in a try/catch. On exception message starting with 'insufficient_stock', call stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) and update orders.status='refunded' and orders.notes='oversold'.
Always return 200 within 5 seconds to prevent Stripe retries.When to use: Paste immediately after the starter prompt — this is the non-negotiable foundation of Stripe integration
Add atomic stock decrement in webhook with refund on oversell
Correct stock-decrement timing with automatic Stripe refund if two customers simultaneously purchase the last unit
Ensure the stripe-webhook edge function calls decrement_stock correctly and handles the oversell case:
For each order_item in the order, after inserting the order_items row, call:
const { error: decrementError } = await supabase.rpc('decrement_stock', { p_product: item.product_id, p_variant: item.variant_id || null, p_qty: item.qty });
If decrementError exists and decrementError.message includes 'insufficient_stock':
1. Update orders.status = 'refunded', orders.notes = 'oversold - auto-refunded' using the service-role client.
2. Call await stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) to refund the customer automatically.
3. Send an 'Out of stock — refund issued' email via Resend to the customer_email.
4. Log the error but still return 200 so Stripe does not retry.
If decrement succeeds, update orders.status = 'paid'.
This flow means two simultaneous checkouts for the last unit: first webhook succeeds and decrements stock to 0. Second webhook hits the decrement_stock RAISE EXCEPTION (stock would go negative), catches it, refunds via Stripe, updates order status, and emails the customer.When to use: Immediately after the raw-body webhook fix — these two prompts form an inseparable pair
Add cart merge on sign-in
Seamless cart merge when a guest signs in mid-shopping — prevents silent cart loss
Update the sign-in flow to merge guest localStorage cart into the DB cart:
1. In src/hooks/useCart.ts, ensure the mergeGuestCart() function is complete:
- Read localStorage key 'cart_items' as JSON (array of {product_id, variant_id, qty})
- If empty or null, return early
- Ensure the user has a carts row: supabase.from('carts').upsert({ user_id: userId }).select().single()
- For each guest item: supabase.from('cart_items').upsert({ cart_id: cart.id, product_id: item.product_id, variant_id: item.variant_id, qty: item.qty }, { onConflict: 'cart_id,product_id,variant_id' }) — the upsert's onConflict behavior: if row exists, update qty = existing_qty + new_qty. Implement this as: first SELECT the existing qty, then INSERT with (existing_qty + item.qty) or INSERT with item.qty if no existing row.
- After all upserts, clear localStorage: localStorage.removeItem('cart_items')
- Re-fetch the DB cart to update state
2. In src/pages/Signin.tsx, after supabase.auth.signInWithPassword succeeds, call useCart().mergeGuestCart() before navigating to /cart.
3. Show a toast after merge: 'Your cart has been saved to your account.'When to use: High-value UX win — any store with guest checkout should implement this before launch
Add order confirmation emails via Resend
Order confirmation email with line items, totals, and order link sent after successful Stripe payment
Add order confirmation emails in the stripe-webhook edge function after successful payment:
After orders.status is set to 'paid' and order_items are inserted, send a confirmation email via Resend:
const emailBody = `
Thank you for your order!
Order #${order.id.slice(0, 8).toUpperCase()}
${orderItems.map(i => `${i.product_name_snapshot} × ${i.qty} — $${(i.unit_price_cents * i.qty / 100).toFixed(2)}`).join('\n')}
Subtotal: $${(order.subtotal_cents / 100).toFixed(2)}
Total: $${(order.total_cents / 100).toFixed(2)}
Shipping to: ${JSON.stringify(order.shipping_address)}
View your order: ${Deno.env.get('SITE_URL')}/orders/${order.id}
`;
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { 'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'orders@your-domain.com',
to: order.customer_email,
subject: `Order confirmed — #${order.id.slice(0, 8).toUpperCase()}`,
text: emailBody
})
});
Make sure RESEND_API_KEY and SITE_URL are set in Cloud → Secrets and your Resend domain is verified.When to use: Once RESEND_API_KEY is in Secrets and your Resend sending domain has verified SPF + DKIM records
Add discount codes table and checkout integration
Discount code system with server-side validation, Stripe integration, and usage tracking
Add discount codes to the checkout flow:
1. Create a new table: discount_codes — id (uuid pk), code (text unique not null), type (text check (type in ('percent','fixed'))), value (int not null — percent 0-100 or fixed cents), max_uses (int nullable), used_count (int default 0), expires_at (timestamptz nullable), active (bool default true).
RLS: no client reads (codes are validated server-side only). Admin writes via app_metadata.role='admin'.
2. In Cart.tsx, add a 'Discount code' input field and 'Apply' button above the subtotal. When Apply is clicked, call a new edge function validate-discount with { code: string, cart_subtotal_cents: int }. Show the discount amount or error.
3. Create supabase/functions/validate-discount/index.ts: looks up the code, checks active=true, expires_at > now(), and used_count < max_uses (if max_uses set). Returns { valid: true, type, value, discount_amount_cents } or { valid: false, reason }.
4. Pass the discount code to create-checkout-session. In that function, apply the discount: for a percent discount, pass discounts: [{ coupon: stripe_coupon_id }] to Stripe Checkout (create the coupon in Stripe dynamically with stripe.coupons.create). For a fixed discount, adjust the line item prices.
5. In the stripe-webhook handler, increment discount_codes.used_count by 1 after a successful order.
6. In /admin/products, add a 'Discount Codes' tab with CRUD for codes.When to use: High-conversion feature — even a simple 10% launch discount can meaningfully increase initial sales
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's default Stripe webhook scaffold uses stripe.webhooks.constructEvent() (synchronous). Deno's WebCrypto is async-only, so the sync version throws immediately.
In supabase/functions/stripe-webhook/index.ts: line 1 of the handler must be const rawBody = await req.text(); Then: const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')); Never call req.json() or JSON.parse before constructEventAsync.Webhook signature verification failedThe webhook handler called req.json() or JSON.parse(await req.text()) before passing to constructEventAsync. The bytes Stripe signed are the exact original bytes — parsing changes them.
Verify that const rawBody = await req.text(); is the absolute first line of the handler, before any await req.json(), before any JSON.parse, before the CORS check. After constructEventAsync verifies the signature, then call JSON.parse(rawBody) if you need the structured event data.
Manual fix
If you see this in Cloud → Logs → Edge Functions, check the function for any req.json() call. That line must be removed. The entire handler should read rawBody as text and pass that directly to constructEventAsync.
negative stock value detected — insufficient_stock: product abc123 (custom exception from decrement_stock)Two simultaneous purchases of the last unit both passed the checkout session creation (which does not lock stock), then both webhooks fired and the second decrement_stock call found stock would go below 0.
This is the decrement_stock function working correctly — it raised the exception. Your webhook handler should have caught this and issued a Stripe refund. If the webhook handler did not catch it: add a try/catch around the supabase.rpc('decrement_stock', ...) call. On error.message.includes('insufficient_stock'), call stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) and update orders.status='refunded'.Stripe Checkout returns error: 'You may only specify one of these parameters: payment_method_types'Lovable scaffolded the Checkout Session with both payment_method_types: ['card'] and automatic_payment_methods: { enabled: true }. These conflict in the 2024+ Stripe API.
In create-checkout-session edge function, remove the payment_method_types line entirely. Keep only automatic_payment_methods: { enabled: true }. Stripe will automatically offer the payment methods configured in your Stripe Dashboard based on the customer's location and currency.Stripe integration doesn't work in Lovable preview — redirect to checkout.stripe.com failsLovable's preview iframe blocks cross-origin redirects to checkout.stripe.com. The webhook also cannot reach the preview URL since it's behind Lovable's internal proxy.
Manual fix
Stripe MUST be tested on a deployed URL. Click the Publish button (top-right), copy the production URL, configure the Stripe webhook endpoint in the Stripe Dashboard to point at https://your-domain/functions/v1/stripe-webhook, and test with card number 4242 4242 4242 4242, any future expiry, any CVC.
Cart count badge shows stale number after adding an itemuseCart hook reads cart count once on mount but does not subscribe to real-time changes on cart_items, so adding an item updates the DB but the badge doesn't reflect it until page refresh.
In useCart.ts, after the initial cart fetch, add a Supabase Realtime channel subscription on cart_items filtered by cart_id. On any INSERT/UPDATE/DELETE event, call the refetch function to update the items array and recompute cartCount. For the guest localStorage path, dispatch a custom window event 'cart-updated' after every mutation and listen for it in the badge component.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo — Stripe webhook iteration alone will consume 30+ credits; Free's 30/mo cap blocks you mid-debug of the most important part of the build
Monthly run cost breakdown
~100–180 credits — starter ~50, raw-body webhook fix ~30, stock decrement ~40, cart merge ~25, email confirmation ~30, plus 2 retry rounds on webhook total| Item | Cost |
|---|---|
| Lovable Pro During build only — cancel after you stop iterating | $25/mo |
| Supabase (Lovable Cloud) Products and orders table stays small for indie commerce; fine up to ~5K orders | $0 |
| Stripe No monthly fee; scales linearly. A $50 average order at 100 orders/mo = ~$16/mo in Stripe fees | 2.9% + $0.30 per transaction |
| Resend Free 3K emails/mo; bumps to $20/mo at ~3K orders/mo | $0 |
| Custom domain Via Lovable Publish → Custom Domain | ~$12/yr |
Scaling notes: Stripe's percentage fee scales linearly forever — no plan jump. Supabase Free is fine up to ~5K orders before storage approaches 500MB (depending on image sizes). Resend free tier breaks at ~3K orders/mo. The real scaling cost is Stripe fees: at $100K/mo GMV you are paying $3,100/mo in Stripe fees — at that point Shopify Payments at 2.5% ($2,500/mo) becomes worth a look.
Production checklist
Steps to take before you share the URL with real users.
Domain & Stripe Setup
Deploy and register Stripe webhook
Publish the app (top-right Publish button), copy the production URL, go to Stripe Dashboard → Webhooks → Add endpoint, set URL to https://your-domain.com/functions/v1/stripe-webhook, select events: checkout.session.completed and payment_intent.succeeded, copy the webhook secret to Cloud → Secrets → STRIPE_WEBHOOK_SECRET.
Update SITE_URL secret
Cloud → Secrets → set SITE_URL to your production domain. Without this, Stripe success_url and cancel_url point to undefined and customers can't return after payment.
Testing
End-to-end Stripe test
On the deployed URL, add a product to cart, go to checkout, use Stripe test card 4242 4242 4242 4242 with any future date and CVC. Verify: (1) redirect to success page, (2) order appears in /orders, (3) stock decremented in admin, (4) confirmation email received.
Oversell test
Set a product stock to 1, open two incognito windows simultaneously, add the same product to cart in both, submit both checkouts at the same time. One should succeed; the other should receive a Stripe refund and see an 'Out of stock' email.
Verify Resend sending domain
Resend dashboard → Domains → Add Domain → add SPF + DKIM DNS records at your registrar → wait for green status. Without this, order confirmation emails go to spam or bounce.
Go Live
Switch from Stripe test to live keys
In Stripe Dashboard, toggle from Test to Live mode, copy the live sk_live_ key to STRIPE_SECRET_KEY in Cloud → Secrets. Create a new webhook endpoint in Live mode (test mode webhooks don't fire in live mode) and update STRIPE_WEBHOOK_SECRET with the live webhook secret.
Frequently asked questions
Why won't Stripe work in Lovable preview?
Lovable's preview runs in an iframe on Lovable's domain. When your checkout edge function returns a Stripe Checkout URL and your code does window.location.href = url, the iframe blocks the redirect to checkout.stripe.com as a cross-origin navigation. The Stripe webhook also cannot reach the preview URL since it is behind Lovable's internal proxy and not a publicly reachable endpoint. Both issues are solved by publishing your project (top-right Publish button) and testing on the deployed URL. Register the webhook at Stripe Dashboard → Webhooks with your production URL before any Stripe testing.
Where in the flow should I decrement stock — at add-to-cart, checkout, or webhook?
Always in the webhook, after payment succeeds. Decrement at add-to-cart and you permanently lock inventory for every abandoned cart. Decrement at checkout submit and customers whose Stripe payment fails leave stock as sold. Decrement in the webhook and you only touch stock after Stripe confirms the money cleared. The decrement_stock() function in the starter prompt raises an exception if stock goes below zero, which the webhook handler catches and uses to trigger an automatic Stripe refund — this is the only way to handle the edge case of two simultaneous purchases of the last unit.
How do I merge a guest cart when the user signs in mid-checkout?
The cart merge follow-up prompt adds this to useCart.ts. After a successful sign-in, mergeGuestCart() reads the localStorage cart, upserts each item into the user's DB cart (adding qtys on conflict rather than duplicating), then clears localStorage. The key is the upsert conflict behavior on (cart_id, product_id, variant_id) — if the user already had the same item in a previous signed-in session, the quantities should sum, not create a duplicate line item. Call mergeGuestCart() in Signin.tsx immediately after signInWithPassword succeeds, before navigating anywhere.
What's the deal with constructEventAsync and Lovable's edge function?
Stripe's webhook signature verification requires HMAC with SHA-256, which uses the WebCrypto API. In Deno (which Lovable Edge Functions run on), WebCrypto is async-only. Stripe's synchronous constructEvent() tries to call WebCrypto synchronously and throws 'SubtleCryptoProvider cannot be used in a synchronous context'. The fix is to use constructEventAsync() with await. The second gotcha: you must pass the raw text body — the exact bytes Stripe signed. If you call req.json() before constructEventAsync, the bytes change and the signature fails. Line 1 of your webhook handler must always be const rawBody = await req.text().
Can I add product variants (size, color) to the schema later?
The starter prompt includes the variants table from the beginning. The VariantSelector component renders variant chips on the product detail page, and both the cart and webhook handle the optional variant_id field. If you initially seed products without variants, they work fine — variant_id is nullable throughout. To add variants to an existing product, use the admin Products page to add variant rows in the edit Sheet. The decrement_stock function handles both cases: if p_variant is not null, it decrements variants.stock; otherwise it decrements products.stock.
How do I handle abandoned carts and email recovery?
The starter prompt does not include abandoned cart recovery — it is a significant addition. The pattern: on checkout page load, save the customer_email to the cart row. Create a pg_cron job that runs every hour and finds carts updated more than 1 hour ago with no corresponding completed order. For each, send a Resend email to the stored email: 'You left something in your cart — here's a link to pick up where you left off.' This requires collecting customer_email before the Stripe redirect, which means adding an email field to your Checkout.tsx before the Stripe button. Budget ~40 credits for this follow-up.
Should I really build this instead of just using Shopify?
For physical goods at any real volume, just use Shopify — $39/mo gets you tax tables, shipping rate engines, fraud detection, and 1,000+ themes you don't want to recreate. Build in Lovable when: you have fewer than 20 SKUs and want owned checkout without Shopify's fee structure, you're integrating commerce into an existing Lovable app where Shopify embed feels wrong, or you sell digital goods and Lemon Squeezy's 5% Merchant of Record fee also feels heavy. 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.