# How to Build a Payment Gateway Integration with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Integrate Stripe into Lovable using the native Stripe connector, PaymentIntent flow, and Edge Function webhooks. You'll build a payment dashboard with products, customer records, and transaction history — powered by Supabase and verified webhook delivery using constructEventAsync on Deno. Works only in deployed mode, not preview.

## Before you start

- Lovable Pro account for Edge Function generation and deployment
- Stripe account (free) with a publishable key and secret key from the Stripe Dashboard
- Stripe webhook secret created at Stripe Dashboard → Webhooks → Add endpoint
- Supabase project with URL and anon key available
- Understanding that Stripe does NOT work in Lovable preview — only in deployed mode

## Step-by-step guide

### 1. Set up the Stripe secrets and Supabase schema

Add your Stripe keys to Lovable's Cloud tab → Secrets so Edge Functions can access them. Then ask Lovable to create the database schema for products, customers, and payments.

```
Create a Stripe payment integration with this Supabase schema:

Tables:
- products: id (uuid pk), name (text), description (text), price_cents (int), currency (text default 'usd'), stripe_price_id (text), is_active (bool default true), created_at
- customers: id (uuid pk), user_id (references auth.users), email (text), stripe_customer_id (text unique), created_at
- payments: id (uuid pk), user_id (references auth.users), customer_id (uuid references customers), product_id (uuid references products), stripe_payment_intent_id (text unique), amount (int), currency (text), status (text default 'pending'), metadata (jsonb default '{}'), created_at, updated_at

RLS policies:
- products: public SELECT for is_active=true, authenticated INSERT/UPDATE for admin role
- customers: users can SELECT/UPDATE their own record (user_id = auth.uid())
- payments: users can SELECT their own payments (user_id = auth.uid())

Generate TypeScript types for all tables.
```

> Pro tip: In Cloud tab → Secrets, add STRIPE_SECRET_KEY (sk_test_...) and STRIPE_WEBHOOK_SECRET (whsec_...) WITHOUT the VITE_ prefix. Only add VITE_STRIPE_PUBLISHABLE_KEY (pk_test_...) with the VITE_ prefix since it is used on the frontend.

**Expected result:** The three tables are created with RLS enabled. TypeScript types are generated. The app shell renders in preview.

### 2. Create the PaymentIntent Edge Function

Ask Lovable to create a Supabase Edge Function that accepts a product ID, creates a Stripe PaymentIntent server-side, and returns the client_secret to the frontend. This keeps your Stripe secret key off the browser.

```
Create a Supabase Edge Function at supabase/functions/create-payment-intent/index.ts.

The function should:
1. Parse the request body: { productId: string, customerEmail: string }
2. Look up the product in Supabase to get price_cents and name
3. Look up or create a Stripe customer using STRIPE_SECRET_KEY from Deno.env
4. Create a Stripe PaymentIntent with amount = product.price_cents, currency = product.currency, customer = stripeCustomerId, metadata = { productId }
5. Insert a row into the payments table with status = 'pending' and stripe_payment_intent_id = paymentIntent.id
6. Return { clientSecret: paymentIntent.client_secret }

Include CORS headers and OPTIONS handler. Use the Stripe API via fetch to https://api.stripe.com/v1/payment_intents with Basic Auth (secret key as username, empty password).

Also upsert the customer into the customers table with stripe_customer_id.
```

> Pro tip: Call the Stripe REST API directly via fetch instead of importing the Stripe Node SDK — Deno handles fetch natively and avoids compatibility issues with npm packages in Edge Functions.

**Expected result:** The Edge Function deploys. Calling it with a valid productId returns a clientSecret string. A pending payment row appears in Supabase.

### 3. Build the product catalog and checkout UI

Ask Lovable to build the product listing page with shadcn/ui Cards and an embedded Stripe Elements checkout Dialog that uses the client_secret from the Edge Function.

```
Build a product catalog page at src/pages/Products.tsx.

Requirements:
- Fetch all is_active products from Supabase
- Display each as a shadcn/ui Card with: name, description, price formatted as currency, and a 'Buy Now' Button
- Clicking Buy Now opens a Dialog
- Inside the Dialog:
  1. Call the create-payment-intent Edge Function to get a clientSecret
  2. Show a loading Skeleton while the Edge Function responds
  3. Once clientSecret arrives, render a Stripe Elements form using loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY) and <Elements stripe={stripePromise} options={{ clientSecret }}>
  4. Inside Elements, render a <PaymentElement /> component for the card form
  5. Add a 'Pay' Button that calls stripe.confirmPayment with return_url pointing to /payment-success
- Install @stripe/react-stripe-js and @stripe/stripe-js via the package manager
- Handle loading, error, and success states with appropriate shadcn/ui Alert components
```

> Pro tip: Set appearance options on the Elements instance to match your Tailwind theme: pass { appearance: { theme: 'stripe', variables: { colorPrimary: '#your-primary-color' } } } to the Elements options.

**Expected result:** The product catalog renders. Clicking Buy Now opens the Dialog with a Stripe card form. Entering test card 4242 4242 4242 4242 and any future expiry completes the payment in test mode.

### 4. Create the webhook handler Edge Function

Build the Stripe webhook receiver that verifies signatures and updates payment status in Supabase. This is the most critical step — without verified webhooks, your app cannot reliably confirm payments.

```
// supabase/functions/stripe-webhook/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import Stripe from 'https://esm.sh/stripe@14?target=deno'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
  apiVersion: '2023-10-16',
  httpClient: Stripe.createFetchHttpClient(),
})

const cryptoProvider = Stripe.createSubtleCryptoProvider()

serve(async (req: Request) => {
  if (req.method !== 'POST') {
    return new Response('Method not allowed', { status: 405 })
  }

  const body = await req.text()
  const signature = req.headers.get('stripe-signature') ?? ''
  const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? ''

  let event: Stripe.Event
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      webhookSecret,
      undefined,
      cryptoProvider
    )
  } catch (err) {
    const msg = err instanceof Error ? err.message : 'Webhook signature failed'
    console.error('Webhook verification failed:', msg)
    return new Response(JSON.stringify({ error: msg }), { status: 400 })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  if (event.type === 'payment_intent.succeeded') {
    const pi = event.data.object as Stripe.PaymentIntent
    await supabase
      .from('payments')
      .update({ status: 'succeeded', updated_at: new Date().toISOString() })
      .eq('stripe_payment_intent_id', pi.id)
  }

  if (event.type === 'payment_intent.payment_failed') {
    const pi = event.data.object as Stripe.PaymentIntent
    await supabase
      .from('payments')
      .update({ status: 'failed', updated_at: new Date().toISOString() })
      .eq('stripe_payment_intent_id', pi.id)
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 })
})
```

> Pro tip: Register this webhook URL in the Stripe Dashboard → Webhooks → Add endpoint AFTER deploying your app. The URL is https://your-project.supabase.co/functions/v1/stripe-webhook. Select the payment_intent.succeeded and payment_intent.payment_failed events.

**Expected result:** A test webhook event sent from the Stripe Dashboard shows a 200 response. The corresponding payment row in Supabase updates from pending to succeeded.

### 5. Build the payment history dashboard

Ask Lovable to create a transaction history page with filtering, status badges, and a revenue chart using Recharts.

```
Build a payment history page at src/pages/Payments.tsx.

Requirements:
- Fetch the current user's payments from Supabase joined with products (to show product name)
- Render as a shadcn/ui DataTable with columns: date (formatted), product name, amount (currency formatted), status (Badge: succeeded=green, pending=yellow, failed=red), Stripe ID (truncated monospace)
- Add a date range Popover filter (last 7 days / last 30 days / all time)
- Add a status Select filter (all / succeeded / pending / failed)
- Above the table, show three summary Cards: Total Revenue (sum of succeeded), Pending Amount, Failed Transactions count
- Add a BarChart using Recharts showing daily revenue for the last 30 days, grouped by date
- Show an empty state illustration with 'No payments yet' when the table is empty
```

**Expected result:** The payments page shows all transactions for the logged-in user. Filters update the table in real time. The revenue chart shows bars for each day with completed payments.

## Complete code example

File: `supabase/functions/create-payment-intent/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  try {
    const { productId, customerEmail } = await req.json()
    const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''

    const supabase = createClient(
      Deno.env.get('SUPABASE_URL') ?? '',
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
    )

    const { data: product, error: productError } = await supabase
      .from('products')
      .select('id, name, price_cents, currency')
      .eq('id', productId)
      .eq('is_active', true)
      .single()

    if (productError || !product) {
      return new Response(JSON.stringify({ error: 'Product not found' }), { status: 404, headers: corsHeaders })
    }

    const customerRes = await fetch('https://api.stripe.com/v1/customers', {
      method: 'POST',
      headers: {
        Authorization: `Basic ${btoa(stripeKey + ':')}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({ email: customerEmail }),
    })
    const customer = await customerRes.json()

    const piRes = await fetch('https://api.stripe.com/v1/payment_intents', {
      method: 'POST',
      headers: {
        Authorization: `Basic ${btoa(stripeKey + ':')}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        amount: String(product.price_cents),
        currency: product.currency,
        customer: customer.id,
        'metadata[productId]': product.id,
      }),
    })
    const pi = await piRes.json()

    await supabase.from('payments').insert({
      stripe_payment_intent_id: pi.id,
      amount: product.price_cents,
      currency: product.currency,
      status: 'pending',
      metadata: { productId: product.id, productName: product.name },
    })

    return new Response(JSON.stringify({ clientSecret: pi.client_secret }), { headers: corsHeaders })
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Internal error'
    return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })
  }
})
```

## Common mistakes

- **Testing Stripe in the Lovable preview instead of deployed mode** — Stripe's native connector and payment flows require a real deployed URL. The Lovable preview iframe does not support Stripe.js loading or PaymentIntent confirmation. Fix: Click the Publish icon (top-right) to deploy your app before testing any payment flows. Use Stripe test mode keys (pk_test_... and sk_test_...) during development so no real charges occur.
- **Using constructEvent instead of constructEventAsync in Deno** — The synchronous constructEvent method uses Node.js crypto APIs that are not available in the Deno runtime. It will throw a runtime error when the webhook receives a real request. Fix: Always use stripe.webhooks.constructEventAsync with a Stripe.createSubtleCryptoProvider() as the fourth argument. This uses the Web Crypto API which is available in Deno.
- **Reading the request body as JSON before passing it to constructEventAsync** — Stripe verifies the webhook signature against the raw request body bytes. If you parse it as JSON first (await req.json()), the byte representation changes and signature verification fails with a 400 error. Fix: Always read the body as text first: const body = await req.text(). Pass this string to constructEventAsync. Never call req.json() before webhook verification.
- **Not handling idempotency in the webhook handler** — Stripe may deliver the same webhook event more than once. Without idempotency checks, you might update the same payment row twice or send duplicate receipts. Fix: Add a webhook_events table with a stripe_event_id unique column. At the start of the webhook handler, INSERT the event ID. If the INSERT fails with a unique constraint error, return 200 immediately without processing — the event was already handled.

## Best practices

- Always create PaymentIntents server-side in an Edge Function. Never pass your Stripe secret key to the frontend or create payment intents from client-side code.
- Use Stripe test mode during all development and staging work. Switch to live keys only when deploying to production, and keep them in a separate set of Secrets.
- Verify every incoming webhook with constructEventAsync before trusting the payload. An unverified webhook could be forged to mark payments as succeeded without actual payment.
- Store stripe_payment_intent_id in your Supabase payments table as the canonical reference. Always reconcile payment status from Stripe webhooks, not from the client-side payment confirmation callback.
- Add a Stripe idempotency key to PaymentIntent creation requests when retrying. This prevents duplicate charges if the network fails between your Edge Function and Stripe.
- Use Stripe's automatic payment methods feature (automatic_payment_methods: { enabled: true }) to accept Apple Pay, Google Pay, and local payment methods without extra code.
- Enable Stripe Radar fraud rules in your Stripe Dashboard to block suspicious payments before they reach your webhook handler. This reduces chargebacks with zero code changes.

## Frequently asked questions

### Why does Stripe not work in the Lovable preview?

Stripe.js requires a real HTTPS domain to load and the PaymentIntent confirmation requires a return_url pointing to your deployed app. The Lovable preview iframe runs on a temporary subdomain and does not fully support Stripe's browser security requirements. Always test Stripe flows after clicking Publish.

### What is the difference between a PaymentIntent and a Checkout Session?

A PaymentIntent gives you full control over the checkout UI by embedding Stripe Elements directly in your page. A Checkout Session redirects users to a Stripe-hosted page with less customization. PaymentIntents are more complex to implement but give you a seamless, branded experience. Checkout Sessions are faster to build. This guide uses PaymentIntents for the embedded experience.

### How do I switch from Stripe test mode to live mode?

Replace the test keys in Cloud tab → Secrets with your live Stripe keys: VITE_STRIPE_PUBLISHABLE_KEY becomes pk_live_... and STRIPE_SECRET_KEY becomes sk_live_.... Also update the STRIPE_WEBHOOK_SECRET to the live webhook's signing secret from the Stripe Dashboard. Republish your app after updating Secrets.

### Can I accept payments in multiple currencies?

Yes. Pass the currency code to the create-payment-intent Edge Function and store it on the payment record. Stripe automatically formats amounts for each currency. Remember that some currencies like JPY (Japanese yen) do not have sub-units, so pass the amount in whole units rather than cents for those currencies.

### What happens if a webhook is not delivered?

Stripe retries failed webhook deliveries for up to 72 hours with exponential backoff. If your Edge Function is down or returns a non-2xx status, Stripe will retry. You can also replay specific events from the Stripe Dashboard → Webhooks → Event log. Build a daily reconciliation job as a backup to catch any permanently missed events.

### How do I issue a refund?

Call the Stripe Refunds API from an Edge Function with the payment_intent_id or charge_id. POST to https://api.stripe.com/v1/refunds with amount and payment_intent as form data using Basic Auth with your secret key. After a successful refund, update the payment row status in Supabase to refunded. Stripe also sends a charge.refunded webhook event you can listen for.

### Is there help available if I need a more complex payment integration?

RapidDev specializes in building production payment systems in Lovable, including multi-vendor payouts, subscription billing, and complex checkout flows. Contact us if you need custom payment architecture beyond what this guide covers.

### How do I test webhooks locally before deploying?

Lovable does not support local development. The recommended approach is to deploy to a staging version of your app, register the staging Edge Function URL in your Stripe webhook configuration, and test using the Stripe Dashboard's 'Send test webhook' button. You can also use the Stripe CLI to forward events to a URL if you have a separate dev environment.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/payment-gateway-integration
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/payment-gateway-integration
