Skip to content
BlackFig

Adding Stripe payments to an AI-built app properly

AntPublished Updated 6 min read

Quick answer

Create Checkout Sessions on the server, then grant access only from a webhook whose Stripe signature you have verified, using the raw request body. Make fulfilment safe to run twice, test with sandbox keys and test cards, then register a separate live webhook with its own signing secret.

On this page
  1. Symptom
  2. Why it happens
  3. The fix
  4. How to prevent it
  5. When to get help

Symptom

Payments look fine in the preview. Then real customers start paying and things drift. Someone pays and never gets upgraded. Someone else gets upgraded twice. A cancelled subscription keeps its paid features. Or the app upgrades a user who never paid at all, because something called the success page directly.

If you have started adding a webhook and it rejects every event, the Stripe Node library throws this:

Both problems come from the same place: the app is not treating Stripe as the source of truth about who has paid.

Why it happens

AI tools build the visible half of payments very well. A pricing page, a button, a redirect to Stripe Checkout, a success page. What they often skip, or set up in a way you have not checked, is the half that runs on the server when nobody is looking.

The most common shapes I see:

  • Access is granted when the success page loads. Stripe's own fulfilment guide says you can't rely on the landing page alone, because a customer can pay and lose their connection before it loads. Subscriptions and delayed payment methods change state later, so they need webhooks.
  • The webhook is not verified. Without checking the Stripe-Signature header, anyone who finds the URL can post a fake "payment succeeded" event.
  • The body is parsed before verification. Frameworks love to turn the request body into JSON. Stripe signs the raw text, so once it has been parsed and re-serialised, the signature never matches.
  • Fulfilment runs twice. Stripe says webhook endpoints might receive the same event more than once, and it retries failed deliveries for up to three days in live mode. If your code adds credits on every event, a retry adds them again.
  • Test and live are mixed up. Sandbox and live mode have separate keys, separate products and prices, and separate webhook secrets. An app that works in test can break on launch day because a price ID or secret was never swapped.

What Lovable and Bolt do for you

Lovable. You can use Lovable's built-in payments, which needs a paid plan, or connect your own Stripe account, which does not. With your own account, Lovable shows a Connect Stripe form, stores the key as a backend secret called STRIPE_SECRET_KEY, and writes edge functions that call Stripe so the key never reaches the browser. Its docs say webhooks are off by default because status is checked directly with Stripe, and that subscriptions match users by email address. That works until a customer signs in with a different email from the one they paid with. Lovable also uses one key at a time, so with a live key, payments made in the preview are real.

Bolt. Bolt's Stripe integration needs authentication and Bolt Database or Supabase, because it runs on edge functions; its docs say it does not work with Firebase. You paste a test secret key under Integrations, then Stripe, pick products, and Bolt creates edge functions for checkout, webhook processing and subscription management. Bolt says it registers the webhook endpoint and verifies signatures for you. If you built a custom Stripe setup before this, Bolt's docs say it cannot migrate to the built-in integration but will keep working, so check which one your project actually uses.

For the tool-specific detail, see fixing Stripe payments in a Lovable app and fixing Stripe payments in a Bolt app.

The fix

The pattern is the same whatever tool built the app. Checkout Sessions are created on the server. A verified webhook triggers fulfilment. Fulfilment is safe to run more than once. The examples use Next.js route handlers with the current stripe Node library, then show the Supabase edge function version Lovable and Bolt apps use.

1. Create the Checkout Session on the server

Never take the price from the browser. Look it up on the server, and attach your own user ID so the webhook knows who paid.

// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getCurrentUser } from "@/lib/auth"; // your own session helper
 
export async function POST(req: Request) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Sign in first" }, { status: 401 });
 
  // The browser creates attemptId once per click of "Upgrade", so a double click
  // or a network retry reuses the same key and Stripe returns the same session.
  const { attemptId } = await req.json();
  const origin = new URL(req.url).origin;
 
  const session = await stripe.checkout.sessions.create(
    {
      mode: "subscription",
      line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
      client_reference_id: user.id,
      customer_email: user.email,
      success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${origin}/pricing`,
    },
    { idempotencyKey: `checkout:${user.id}:${attemptId}` }
  );
 
  return NextResponse.json({ url: session.url });
}

That idempotencyKey is Stripe's own retry protection. Its docs say keys can be up to 255 characters, can be pruned after they are at least 24 hours old, and should not contain sensitive data such as email addresses.

2. Verify the webhook with the raw body

In a Next.js route handler, req.text() gives you the untouched body. Do not call req.json() first.

// app/api/stripe/webhook/route.ts
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { fulfilCheckout, endSubscription } from "@/lib/billing";
 
export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    console.error("Stripe signature check failed", err);
    return new Response("Invalid signature", { status: 400 });
  }
 
  switch (event.type) {
    case "checkout.session.completed":
    case "checkout.session.async_payment_succeeded":
      await fulfilCheckout(event.data.object.id);
      break;
    case "customer.subscription.deleted":
      await endSubscription(event.data.object.id);
      break;
  }
 
  return new Response(null, { status: 200 });
}

Stripe asks for a quick 2xx. If you set a success_url and have a registered webhook endpoint listening for checkout.session.completed, Checkout waits up to 10 seconds for your response before redirecting the customer, so keep this handler fast. Heavy work belongs in a queue.

3. Make fulfilment safe to run twice

Stripe's fulfilment guide says your function might be called several times, possibly at the same time, for the same session. It should retrieve the session, check payment_status, and record that it has fulfilled it. A unique constraint does the hard part.

create table fulfilments (
  stripe_session_id text primary key,
  user_id uuid not null,
  created_at timestamptz not null default now()
);
// lib/billing.ts (fulfilment part)
import { stripe } from "@/lib/stripe";
import { pool } from "@/lib/db"; // a pg Pool
 
export async function fulfilCheckout(sessionId: string) {
  const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["line_items"] });
  if (session.payment_status === "unpaid") return; // async methods arrive later
  if (!session.client_reference_id) throw new Error(`No user on session ${session.id}`);
 
  const db = await pool.connect();
  try {
    await db.query("begin");
    const { rowCount } = await db.query(
      "insert into fulfilments (stripe_session_id, user_id) values ($1, $2) on conflict do nothing",
      [session.id, session.client_reference_id]
    );
    if (rowCount === 1) {
      await db.query("update profiles set plan = 'pro' where id = $1", [session.client_reference_id]);
    }
    await db.query("commit");
  } catch (err) {
    await db.query("rollback");
    throw err;
  } finally {
    db.release();
  }
}

Because the insert and the upgrade share a transaction, a crash halfway leaves nothing recorded, and Stripe's retry finishes the job. You can call the same function from the success page for an instant upgrade. The webhook still guarantees it happens.

4. The same check in a Supabase edge function

Lovable and Bolt apps run Stripe code in Supabase edge functions on Deno. The signature check needs the async version and a Web Crypto provider, following Supabase's own example.

// supabase/functions/stripe-webhook/index.ts
import Stripe from "npm:stripe@^23";
 
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();
 
Deno.serve(async (req) => {
  const signature = req.headers.get("Stripe-Signature");
  const body = await req.text();
  if (!signature) return new Response("Missing signature", { status: 400 });
 
  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET")!,
      undefined,
      cryptoProvider
    );
  } catch (err) {
    return new Response((err as Error).message, { status: 400 });
  }
 
  // Same rule as above: fulfil from here, and make it safe to run twice.
  console.log("Verified Stripe event", event.id, event.type);
  return Response.json({ received: true });
});

Stripe calls this without a Supabase login, so Supabase's own Stripe example is deployed with JWT verification off. For this function that is supabase functions deploy --no-verify-jwt stripe-webhook. The signature check is what protects it instead. On Bolt's built-in integration this function already exists, so read it rather than adding a second one.

5. Test in a sandbox, then go live on purpose

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the whsec_... secret it prints into STRIPE_WEBHOOK_SECRET
stripe trigger checkout.session.completed

Then pay through the real Checkout page with test cards:

CardWhat it tests
4242 4242 4242 4242Successful payment
4000 0025 0000 31553D Secure authentication
4000 0000 0000 0002Generic decline
4000 0000 0000 9995Insufficient funds

Pay, then close the tab before the success page loads. The account should still upgrade. That one test catches most broken payment setups.

For launch, Stripe's go-live checklist says objects created in a sandbox, such as products and plans, aren't usable in live mode, so recreate them and update the price IDs. Register a live webhook endpoint, which gets its own whsec_ secret. Swap to live keys. Stripe recommends restricted keys (rk_live_) with only the permissions you need over unrestricted secret keys. On Lovable, that means submitting a live key in the Update Stripe secret key form and asking it to switch to live price IDs.

How to prevent it

  • Treat the webhook as the only thing allowed to grant paid access. The success page can show progress, nothing more.
  • Store Stripe IDs (customer, subscription, session) against your users, rather than matching on email.
  • Handle the unhappy events too: customer.subscription.deleted, failed renewals and refunds.
  • Keep secrets in your host's secrets store, never in code or chat, and rotate any key that was ever pasted somewhere public.
  • Re-run the close-the-tab test after every change an AI tool makes to billing code.

Payments are one item on the launch checklist for AI-built apps. If you are also moving hosts, moving off Lovable or Bolt hosting covers updating webhook URLs, and setting up analytics shows how to count a purchase from the webhook rather than the browser. The wider path is in the prototype to product guide.

When to get help

If money is already flowing and your records disagree with the Stripe dashboard, fix that before adding anything else. The same goes for subscriptions matched by email, or a webhook nobody has verified.

This is a standard part of my Rescue Sprint, and the free vibe-code health check is a quick first read on where your app stands.

Sources

The launch checklist for founders, as a PDF

About 40 checks across legal, payments, domain and email, analytics, backups and support.

You get the PDF now and two short follow-ups over the next week. Reply “stop” and the rest never arrive.

Questions founders ask

Does Lovable set up Stripe webhooks for me?

Not by default when you connect your own Stripe account. Lovable's docs say the integration does not use webhooks by default, because your app checks payment and subscription status directly with Stripe. You can ask Lovable to build a webhook function and register it in Stripe yourself.

Does Bolt verify Stripe webhook signatures?

Bolt's docs say its Stripe integration handles webhook endpoint registration and signature verification automatically. It runs on Supabase edge functions, so it needs Bolt Database or Supabase and does not work with Firebase.

Why does my webhook signature check fail?

Almost always because the body was parsed as JSON before verification, or the signing secret is wrong. Stripe needs the raw body, and the test and live endpoints have different secrets.

Can I use real cards in test mode?

No. Use Stripe's test cards, such as 4242 4242 4242 4242 with any future expiry and any 3-digit CVC. Stripe's terms prohibit testing in live mode with real card details.

Ant

Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me