Skip to content
Lovable Field Guide
Start here

Taking payments in a Lovable app: Stripe or built-in payments

Lovable Stripe is set up through prompts, and it's a different thing from Lovable's built-in payments. How to choose, plus the webhook code you own.

12 min read

Lovable has two separate payment stories, and they are not the same product. You either bring your own Stripe account, which Lovable sets up through prompts, or you use Lovable’s built-in payments, where Lovable handles account creation, connection and technical setup with Paddle or Stripe. Per Lovable’s docs you cannot run both on one project, so this is a decision to make before you write any checkout code.

Below: what each path does, how to choose between a merchant of record and direct Stripe, and then the part neither handles for you: the server-side correctness work that stops your app handing out paid plans for free.

Bring your own Stripe Lovable built-in payments
How it’s wired You store a key, Lovable generates backend functions that call Stripe Lovable handles account creation, connection and technical setup
Providers Stripe Paddle or Stripe (one per project)
Lovable plan needed Works on Free Paid plan required
Who is the merchant You Paddle as full merchant of record; Stripe optionally via Managed Payments
Tax and invoicing Yours to solve Handled by Paddle as MoR
Headline rate Your own Stripe pricing Paddle: 5.0% + 50c, or flat 10% on microtransactions under $10
Control over the flow Total: it’s your code Lovable’s setup

The plan gating, provider list and Paddle rates in that table come from Lovable’s own documentation as of September 2026. For the wider picture of how Lovable wires anything external, the integrations overview covers the six connector types; Stripe-with-your-own-key is effectively the “any API” path with generated backend code.

Path 1: bring your own Stripe account

This is the one most people mean by “lovable stripe”, and the one most often described wrongly. There is no integration toggle. You describe the payment flow in chat, and Lovable writes backend functions that call the Stripe API with a key you stored separately.

Where the key lives

The key goes in as a backend secret named STRIPE_SECRET_KEY, submitted through a dedicated form. Lovable’s docs are explicit that it never lands in app code and never goes through chat. Where you find it afterwards depends on your backend:

  • Lovable Cloud project: it shows up in the Secrets section with a Lovable badge.
  • Your own Supabase project: it syncs to the edge function secrets in your Supabase dashboard.

That difference is one of the practical edges of the Cloud versus your own Supabase decision: with Cloud the key is managed inside Lovable’s editor; with Supabase it sits in infrastructure you own and can rotate without opening Lovable.

Test versus live is decided entirely by that key’s prefix. sk_test_ or rk_test_ means Stripe’s test environment; an sk_live_ or rk_live_ key is real money. No separate mode switch exists to forget about, which cuts both ways, since swapping the secret is the whole cutover.

What Lovable generates

Per the Stripe integration docs, Lovable will build checkout functions and buy buttons, subscription checkout and status checks, customer portal integration, product and price creation inside your Stripe account (with your approval), and subscription matching by user email.

The limits worth knowing before you start

Three from the docs. The last two bite during development; the first one bites in production:

  1. Webhooks are optional and off by default. The generated app polls Stripe for payment status instead. More on why that matters below.
  2. The in-Lovable payments view is read-only. Stripe’s dashboard remains the place you actually operate the business.
  3. The customer portal doesn’t work inside the preview panel. Test it on the published URL, in a real browser tab, or you’ll spend an afternoon debugging a sandbox restriction.

And, from the same page: you cannot use your own Stripe account and Lovable’s built-in payments at once.

Path 2: Lovable’s built-in payments

The second product is a different thing entirely: Lovable handles account creation, connection and technical setup, and recommends Paddle or Stripe, or offers both. The documented terms, as of September 2026:

  • Paddle acts as a full merchant of record, handling tax, compliance and invoicing, at 5.0% + 50c per transaction. Microtransactions under $10 are charged differently: a flat 10%.
  • Stripe offers optional merchant-of-record service through Managed Payments.
  • Using a provider through Lovable costs the same as going direct. There’s no Lovable markup layered on the provider’s rate.
  • Built-in payments need a paid Lovable plan. Bring-your-own-Stripe works on Free.

The constraints are specific: digital products only (physical goods route through the Shopify integration), one payment provider per project, one subscription per user per environment by default, and (the one that catches teams) projects with payments cannot be remixed or forked. If your workflow depends on remixing to spin up a variant or a client copy, adding payments closes that door.

Merchant of record, or direct Stripe?

A merchant of record is a different legal entity selling your product. Paddle becomes the seller on the customer’s statement and on the invoice, which means Paddle owes the VAT, files the returns, and absorbs the registration surface.

Compare that to what direct Stripe leaves on your desk. Stripe Tax calculates and collects the right amount, but calculation is the easy half. You still register wherever you cross a threshold and file on each jurisdiction’s schedule: an OSS registration and quarterly filings in the EU, economic nexus tracked state by state in the US. Neither is hard; both are ongoing, and both fail quietly until they fail expensively.

Take the merchant-of-record deal when:

  • You sell digital products internationally and have no tax function.
  • Your customers are consumers across many countries, so VAT applies from the first sale rather than after a threshold.
  • The 5.0% + 50c is comfortably inside your margin and you’d rather pay it than own the compliance calendar.

Go direct on Stripe when:

  • You sell B2B, where reverse charge means you often aren’t collecting VAT at all.
  • Your volume makes the spread real money. Two points of difference on $50k a month is $1,000 a month you are buying compliance with.
  • You need Stripe primitives an MoR layer doesn’t expose: usage-based billing, Connect, custom dunning, metered invoicing, or direct access to the raw event stream.
  • Your average order value is low. A 50c fixed component on a $4 sale is brutal; note that Lovable’s docs put sub-$10 transactions on the flat 10% instead, which is the more honest rate at that size.

The five things you have to get right yourself

Neither path makes your billing correct. Generated code gets you a working checkout; these five get you one you can trust with other people’s money. All of it applies whether the code sits in a Supabase edge function or, on the TanStack Start stack Lovable made the default for new projects on 13 May 2026, in a server function inside the app: the shape differs, the rules don’t.

One thing about the snippets below: they are written in the Deno edge-function shape, because that is what Lovable generates on Cloud and on your own Supabase. Deno.env.get() does not exist on the TanStack Start stack. That code runs on Cloudflare Workers, where secrets arrive as request-time bindings rather than a process environment, so read STRIPE_SECRET_KEY off the env your server function is handed instead. Everything else below is identical on both stacks.

1. The client never names the price

If a browser can send an amount to your backend, someone will send a different one. The server picks the price; the client picks a product key the server resolves.

// Server-side only. Depends on: the Stripe SDK import Lovable generated in this
// file, STRIPE_SECRET_KEY as a backend secret, a SITE base URL, and `user`
// resolved server-side from the verified session: never from the request body.
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
const SITE = Deno.env.get('SITE_URL')!

// requireUser is yours: it verifies the request's auth token and returns the
// matching row. If the user identity comes out of req.json(), you have built
// the exact hole this section exists to close.
const user = await requireUser(req)
if (!user) return new Response('unauthorized', { status: 401 })

// The allow-list IS the pricing logic. Nothing else may set an amount.
const PLANS = {
  pro_monthly: 'price_1XXXXXXXXXXXXXXXXXXXXXXX',
  pro_yearly:  'price_1YYYYYYYYYYYYYYYYYYYYYY',
} as const

const { plan } = await req.json()
// Own properties only, and a string. A plain `PLANS[plan]` lookup happily
// returns an inherited function for plan = "constructor" or "toString".
if (!Object.prototype.hasOwnProperty.call(PLANS, plan) ||
    typeof PLANS[plan as keyof typeof PLANS] !== 'string') {
  return new Response('unknown plan', { status: 400 })
}
const priceId = PLANS[plan as keyof typeof PLANS]

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: priceId, quantity: 1 }],
  customer: user.stripe_customer_id ?? undefined,
  client_reference_id: user.id,        // survives into the webhook
  metadata: { user_id: user.id },      // so does this
  success_url: `${SITE}/welcome?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${SITE}/pricing`,
})

client_reference_id and metadata both come back on the webhook event, which is how you tie a Stripe payment to a row in your database without guessing from an email address. And if you find yourself building a price_data object out of request input, stop: that’s the same vulnerability wearing a Stripe-shaped hat.

2. The client never asserts entitlement

The second half of the same rule. A paid-feature check must read your own database, on the server, on every request that matters. localStorage.isPro, a claim in a JWT you minted before checkout, or a React state flag set on the success page are decorations: fine for hiding an upsell, useless as a gate. Generated code often gets this right in the obvious place, the pricing page, and wrong in the interesting one: the API route that does the expensive thing. Audit the second.

3. Webhooks are the source of truth

Lovable’s default is polling: after checkout the app asks Stripe whether the session is paid. For a one-off purchase where the user waits on the success page that’s genuinely fine, and it’s why Lovable can ship a working flow without you configuring anything in Stripe.

It stops being fine the moment something happens without a browser attached. A customer closes the tab before redirect. A bank debit settles two days later. A subscription renews at 4am on day 31, or a card is declined on that renewal. None of those produce a page view, so a polling app never learns about them and your entitlement table drifts away from reality.

Per Lovable’s docs, if you want webhooks Lovable writes the handler, but you configure the event destination and signing secret in Stripe’s dashboard. That signing secret is a second secret you create yourself: Stripe hands it to you when you add the event destination, the name is your choice, and unlike STRIPE_SECRET_KEY it will not exist in the Secrets panel until you put it there. Whatever Lovable writes, verify the signature before reading a byte of the body:

// Verify, then dedupe, then act. In that order, always.
const sig = req.headers.get('stripe-signature')
const raw = await req.text()   // RAW body. Parsing first breaks the signature.

let event: Stripe.Event
try {
  // Use the async variant on Deno / Workers runtimes: the sync one wants
  // Node's crypto and will throw at the edge.
  event = await stripe.webhooks.constructEventAsync(
    raw, sig!, Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
  )
} catch {
  return new Response('invalid signature', { status: 400 })
}

An unverified webhook endpoint is an unauthenticated public API that grants subscriptions: anyone who finds the URL can POST themselves a lifetime plan. Verification isn’t optional hardening, it’s the whole gate.

4. Make everything idempotent

Stripe delivers at least once, retries failures with exponential backoff over a period of days, and does not promise ordering. You will see the same event twice, and you will sometimes see customer.subscription.updated before the checkout.session.completed that created it. Both are normal.

Deduplicate on event.id with a unique constraint, and let the database do the work. The subtlety is when a row counts as a duplicate: record receipt and completion separately, or a handler that throws halfway through leaves a row behind that makes Stripe’s retry look like a duplicate, and the event is lost for good.

// stripe_events(id text primary key, status text not null default 'processing',
//               received_at timestamptz default now())
// `db` is the service-role Supabase client: the anon client is blocked by RLS.
const { error } = await db.from('stripe_events').insert({ id: event.id })

if (error?.code === '23505') {                        // 23505 = unique_violation
  const { data: prior } = await db.from('stripe_events')
    .select('status').eq('id', event.id).single()
  if (prior?.status === 'handled') {
    return new Response('duplicate, already handled', { status: 200 })
  }
  // Still 'processing': an earlier attempt died mid-flight. Fall through and redo.
}

await handleEvent(event)   // convergent, so re-running it is safe
await db.from('stripe_events').update({ status: 'handled' }).eq('id', event.id)

Return 200 on a genuine duplicate. Returning an error makes Stripe retry an event you’ve already processed, which is how one duplicate becomes fifty.

Handle out-of-order delivery by making handlers convergent rather than incremental: on any subscription event, re-read the subscription from Stripe and write its current state instead of applying a delta. That costs one API call and removes a whole category of bug.

On the outbound side, pass an idempotency key when you create anything chargeable, so a retried request doesn’t produce two subscriptions:

// `params` is the checkout-session object from the first snippet.
// Stripe idempotency keys expire after 24 hours, so a per-day stamp is the
// longest window that still does anything.
const dayStamp = new Date().toISOString().slice(0, 10)

await stripe.checkout.sessions.create(params, {
  idempotencyKey: `checkout:${user.id}:${plan}:${dayStamp}`,
})

5. Handle the whole subscription lifecycle

A subscription is not a boolean. Stripe will hand you incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid, and paused, and code that only knows about active will get every edge case wrong in the direction of losing you customers.

Status What happened Grant access?
incomplete First payment hasn’t succeeded yet (often 3-D Secure pending) No
incomplete_expired That first payment never completed in time No
trialing In trial, no payment taken yet Yes
active Paid and current Yes
past_due A renewal failed; Stripe is retrying Yes, during a defined grace period
unpaid Retries exhausted No
canceled Ended No
paused Trial ended without a payment method, under a pause behavior No, usually

Three more things to handle. cancel_at_period_end means the customer has churned but stays entitled until the period ends: treating it as immediate cancellation is a refund request waiting to happen. A trial ending produces an active subscription and a real charge, so listen for invoice.paid and invoice.payment_failed too. And current_period_end moved off the subscription object onto its items in a newer Stripe API version, so the shape you get depends on the version your account and SDK are pinned to: check yours against Stripe’s API changelog before you write the read. Below that version it is still on the subscription; at or above it, read it from the item.

The minimum viable event set for a subscription product: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed.

Test mode, and what “tested” actually means

The key prefix is the whole switch, so a test run is a test key plus Stripe’s test cards. 4242 4242 4242 4242 succeeds. 4000 0000 0000 9995 declines for insufficient funds. 4000 0025 0000 3155 forces a 3-D Secure challenge: the one that puts a subscription into incomplete and shows whether your success page copes.

Three things people skip:

  1. Test-mode webhooks need their own endpoint and signing secret. Test and live are separate worlds in the Stripe dashboard. A handler that works in test and silently 400s in production is usually a live signing secret nobody created.
  2. Use Stripe’s test clocks for anything subscription-shaped. Advancing a clock through a renewal and a failed payment takes minutes, and is the only way to hit past_due before a real customer does.
  3. Re-test on the published URL. Lovable’s docs note the customer portal doesn’t work in the preview panel. As of September 2026 the docs don’t say anything about redirect behavior there, so test your success_url and cancel_url on the published URL too rather than assuming the preview is representative.

If you move your backend, your webhook endpoint moves too

Webhook endpoints are configured in Stripe, pointing at a URL your backend serves. Stripe has no idea you migrated. Change where that URL resolves and events keep flowing to the old address until you notice: silently, because your app doesn’t error, it just stops learning about renewals.

This bites in every direction: moving from Lovable Cloud to your own Supabase, exporting and self-hosting, or changing your custom domain. Lovable’s docs are clear that there’s no one-click migration between Cloud and your own Supabase in either direction, and the full migration guide covers the wider inventory of things that don’t travel with your code. Payments-specific checklist:

  1. Stand up the new endpoint and verify it before cutting over. Send a test event from the Stripe dashboard.
  2. Add the new endpoint alongside the old one and let both run for a day. Duplicate events are harmless once you have the event.id dedupe in place.
  3. Store the new endpoint’s signing secret in the new backend. It is not the same string as the old one, and re-create STRIPE_SECRET_KEY there too: secrets are not part of a code export.
  4. Watch Stripe’s webhook delivery log for failures on the old endpoint, then remove it.
  5. Check the Customer Portal return URL and your success_url / cancel_url values for the old domain.

What to do next

  1. Decide the path first (your own Stripe, or built-in payments with an MoR) and write down why. You cannot run both, and unwinding means rebuilding.
  2. Grep the generated backend code for anywhere an amount or price comes from the request body. Fix those first.
  3. Find every server route that gates a paid feature and confirm it reads entitlement from your database. A flag the client sends can be faked.
  4. If you sell subscriptions, turn webhooks on: ask Lovable for the handler, configure the event destination and signing secret in Stripe yourself, and add the event.id dedupe table in the same pass.
  5. Run a test clock through trial end, renewal and a failed payment. Better to find past_due unhandled here than in a support ticket.

Found something wrong or out of date? Lovable changes fast and we'd rather fix a guide than let it rot.

Frequently asked questions

Is Stripe a one-click integration in Lovable?
No. Lovable's Stripe documentation describes a prompt-driven flow, not a toggle: you store your Stripe secret key as a backend secret and Lovable generates edge functions that call Stripe with it. That is different from Lovable's separate built-in payments product, where Lovable handles account creation, connection and the technical setup for you using Paddle or Stripe.
Where does my Stripe secret key get stored in a Lovable project?
As a backend secret named STRIPE_SECRET_KEY, submitted through a dedicated form rather than pasted into chat or into app code. On a Lovable Cloud project it appears in the Secrets section with a Lovable badge. On a project connected to your own Supabase, it syncs to the edge function secrets in your Supabase dashboard.
Does Lovable set up Stripe webhooks for me?
Not by default. Per Lovable's docs, webhooks are optional and the generated app polls Stripe for payment status instead. If you want webhooks, Lovable will write the handler function, but you configure the event destination and the signing secret in the Stripe dashboard yourself.
Can I use my own Stripe account and Lovable's built-in payments at the same time?
No. Lovable's docs state that you cannot use your own Stripe account together with built-in payments, and that a project has one payment provider. Built-in payments also require a paid Lovable plan, while bring-your-own-Stripe works on the free plan.
What does Lovable's built-in payments cost with Paddle?
Lovable's payments documentation gives Paddle as 5.0% + 50c per transaction, with microtransactions under $10 charged at a flat 10% instead. Lovable states that using a provider through Lovable costs the same as going direct: you pay the provider's rate with no Lovable markup.
How does Lovable know whether to use Stripe test mode or live mode?
Purely from the key prefix. A key beginning sk_test_ or rk_test_ puts you in Stripe's test environment; a live key puts you in production. There is no separate environment switch to forget, but it also means swapping the secret is the entire cutover, so verify it deliberately.

Read next