afterbuild/ops
Resource

Stripe integration checklist for Bolt.new apps.

Bolt.new wires a Stripe checkout button. That's 10% of a payment integration. Here's the checklist for the other 90% — the part that actually runs your subscription business.

By Hyder ShahFounder · Afterbuild LabsLast updated 2026-04-15

Bolt.new is great at generating a Stripe Checkout button. Prompt it, and within seconds you have a server function creating a Checkout session and a client button redirecting to the hosted Stripe page. It works. The first time a user clicks it, they pay, they come back to your success page, and Bolt considers its job done.

That is where the problems start. Because everything after that first click — the reason Stripe exists as a platform rather than a payment form — is not generated, not tested, and not visible to the AI. Stripe's 2025 benchmark on AI agents building real integrations documented this gap quantitatively: AI-built Stripe integrations plateau on webhook idempotency, retry handling, and failure paths. Those are the parts that keep your subscription business running.

This article is the complete checklist for the 90 percent of Stripe integration that Bolt does not write. Work through each section and your app goes from “can accept one payment” to “can run a real subscription business.”

1. What Bolt.new generates vs what you need

Here is the honest accounting.

What Bolt generates

What Bolt does not generate

The missing pieces are not edge cases. They are the core of running a subscription business. Without them, you cannot reliably grant access on payment, revoke on cancellation, handle payment failures, or let users self-serve. Every one of those becomes a manual process — or a quiet bug.

2. Webhook handler setup

Create the webhook endpoint. In Next.js App Router, this lives at src/app/api/webhooks/stripe/route.ts. In a Vite/Express Bolt app, it might be a standalone Express route at server/routes/stripe-webhook.ts. The location differs; the requirements do not.

The handler must do four things, in order:

  1. Read the raw request body. Not parsed JSON. Stripe computes the signature over the raw bytes, and if your framework auto-parses JSON, the signature verification will fail even on legitimate events.
  2. Verify the signature using stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET). The signature is in the stripe-signature request header. If verification throws, return 400 and stop.
  3. Dispatch on event.typeto the appropriate handler function. Events you don't care about, log and return 200.
  4. Return 200 to acknowledge receipt. If your handler takes more than a few seconds, queue the work and return 200 immediately, otherwise Stripe will retry on timeout.

Concrete shape:

// src/app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const body = await req.text(); // raw body
  const sig = req.headers.get('stripe-signature')!;
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutCompleted(event.data.object);
      break;
    // ... other events
  }
  return new Response('ok', { status: 200 });
}

Never skip signature verification. The webhook URL is public. Without verification, any attacker who knows the URL can POST a fake checkout.session.completed event with their own customer ID and grant themselves premium access. Signature verification is the only thing that makes webhooks safe.

3. The 8 event types you must handle

Stripe fires dozens of event types. Most are noise. These eight drive the state transitions your app needs to track.

checkout.session.completed

Fired when a user completes the Checkout flow (payment captured, subscription created). Your handler should: extract the customer ID and subscription ID from the event, look up the corresponding user in your database (you should have passed a client_reference_id when creating the session), insert or update thesubscriptionsrow, and grant access. If you don't handle this, paid users have no access record.

invoice.paid

Fired on successful recurring payments (monthly, annual renewals). Your handler should: update current_period_endon the subscription row to reflect the new billing period. Logged if nothing else — you want a trail of successful payments. If you don't handle this, access may expire on the client side based on a stale current_period_end.

invoice.payment_failed

Fired when a recurring charge fails (expired card, insufficient funds, bank decline). Your handler should: mark the subscription as past_duein your database, optionally email the user with an update card link (pointing at the Customer Portal), and after a grace period decide whether to revoke access. If you don't handle this, users continue using the app while unpaid.

invoice.payment_action_required

Fired when 3D Secure or other strong customer authentication is required for the charge to complete. Your handler should: email the user a link to authenticate the payment. If you don't handle this, users in SCA-regulated regions see their subscription quietly fail.

customer.subscription.updated

Fired when the subscription changes — plan upgraded, plan downgraded, quantity changed, trial converted. Your handler should: update plan_id, status, and current_period_endon the subscription row to match the event. If you don't handle this, an upgraded user still has the old plan's access.

customer.subscription.deleted

Fired when a subscription ends — user cancelled, payment failures exhausted, manual termination. Your handler should: mark the subscription canceledin your database, immediately revoke access to paid features. If you don't handle this, former customers retain access indefinitely.

customer.subscription.trial_will_end

Fired three days before a trial ends. Your handler should: email the user reminding them their trial converts soon. Optional but extremely effective at reducing involuntary churn from users who forgot they were in trial.

payment_intent.payment_failed

Fired when a one-time payment fails. Relevant if you sell one-off products alongside subscriptions. Your handler should: email the user or show a retry UI.

4. Subscription state sync

Every webhook event that changes subscription state must update a row in your database. This is how your app decides who can use paid features — you never call the Stripe API from your request path; it is too slow and too fragile. You read from a subscriptions table that mirrors Stripe.

Recommended schema:

create table subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id),
  stripe_customer_id text not null,
  stripe_subscription_id text not null unique,
  status text not null, -- active, trialing, past_due, canceled
  plan_id text not null,
  current_period_end timestamptz,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);
alter table subscriptions enable row level security;
create policy "users read own subscription"
  on subscriptions for select using (auth.uid() = user_id);

Notice the RLS policy: only the owning user can read their row. The webhook handler uses the service role key to write, which bypasses RLS; the client reads with the anon key under RLS.

The key principle:Stripe is the source of truth. Your database reflects Stripe's state. Never calculate subscription status from your own business logic like “they paid in the last 30 days so they're active.” Always read from the table that Stripe's webhooks keep in sync. If they diverge, trust Stripe — a reconciliation cron job is a good idea for catching drift.

In your app, access checks are then a single query: select status from subscriptions where user_id = $1 and status in ('active', 'trialing'). Fast, reliable, protected by RLS.

5. Idempotency

Stripe delivers webhook events at-least-once. A network timeout, a slow response, a 500 error — any of these cause Stripe to retry. Your handler may see the same event twice, or ten times. If processing the event twice produces double the effect, you have a bug waiting to happen.

Example of what goes wrong without idempotency: a checkout.session.completed arrives, you grant a credit to the user, return 200 just after Stripe times out on its side, Stripe retries, you grant another credit. The user now has two credits for one payment. Or worse, you double-insert rows with non-unique constraints and end up with multiple subscription records for the same Stripe subscription ID.

The standard idempotency pattern: a processed_events table with stripe_event_id as a primary key. Before processing an event, attempt to insert the ID. If the insert succeeds (new event), process. If the insert fails due to unique constraint violation (duplicate), return 200 immediately without processing again.

create table processed_events (
  id text primary key, -- Stripe event ID
  processed_at timestamptz default now()
);

// In handler:
try {
  await db.insert(processedEvents).values({ id: event.id });
} catch (e) {
  if (isUniqueViolation(e)) return new Response('ok', { status: 200 });
  throw e;
}
// continue processing

Alternative: use an idempotency check on the state change itself (e.g. upsert with a WHERE clause that checks the event was not already applied). Either pattern works if implemented consistently. The wrong answer is “I'll just assume Stripe never retries” — because it does, routinely.

6. Customer portal

The Stripe Customer Portal is a hosted page where your users can update their card, download invoices, change plans, and cancel — without emailing you. It is half an hour of setup that eliminates a category of support tickets forever.

Setup steps:

  1. In the Stripe dashboard, go to Settings → Billing → Customer portal.
  2. Enable the portal. Configure which actions users can take: update payment method, cancel subscription, switch plans, download invoices. Set a business policy for cancellation (immediate vs end of period).
  3. Add the return URL — where users land when they click “back to the app.” Typically your dashboard.
  4. Create a server endpoint that generates a portal session URL for the authenticated user. Call stripe.billingPortal.sessions.create({ customer: customerId, return_url }). Return the session URL.
  5. Wire your “Manage subscription” button in the app to call the endpoint and redirect.

That is the whole implementation. Once users have access to the portal, almost every support request related to billing goes away — they handle it themselves.

7. Testing the full integration

You cannot launch a Stripe integration you have not tested end to end. Stripe has made this straightforward with the Stripe CLI.

Install and authenticate:

brew install stripe/stripe-cli/stripe
stripe login

Forward webhooks from Stripe to your local dev server: stripe listen --forward-to localhost:3000/api/webhooks/stripe. The CLI prints a signing secret — copy it into your .env.local as STRIPE_WEBHOOK_SECRET. Now every webhook Stripe fires lands in your dev server with a real signature for verification.

Trigger the events and verify each produces the right state:

Test the full user journey manually too: use a Stripe test card (4242 4242 4242 4242), go through Checkout, reach the success page, verify access works, open the Customer Portal, change plans, cancel. Every step should do the right thing in your database and your UI.

Finally, test one thing most people skip: the decline path. Use Stripe's test card for decline (4000 0000 0000 0002). Try to check out. Verify no subscription is created, no access is granted, the user sees a helpful error rather than a crashed page. The decline path is the one most likely to be broken because it is rarely exercised, and it is the one most likely to cost you a customer if it is.

See how rescue works →

FAQ
Does Bolt.new generate Stripe webhooks?
No. Bolt generates a Stripe Checkout session creation function and a success redirect page. It does not generate a webhook handler. Without webhooks, you can't confirm payment server-side, handle failures, or revoke access on cancellation.
Why does Stripe require webhook signature verification?
Because the webhook endpoint is a public URL. Anyone can POST to it. Without signature verification, a malicious actor could send a fake checkout.session.completed event and grant themselves premium access. The Stripe signature verification step confirms the event actually came from Stripe.
What happens if I don't handle invoice.payment_failed?
Users whose payment fails continue to have premium access indefinitely. Stripe will retry the charge a few times over the next few weeks, then mark the subscription as unpaid and eventually cancel it — at which point customer.subscription.deleted fires. But between the failure and the cancellation, you're giving away access you're not being paid for.
Do I need the customer portal?
Yes, practically speaking. Without it, users who want to update their card, upgrade their plan, or cancel have to email you. You handle it manually in the Stripe dashboard. The portal is 30 minutes of setup that eliminates a category of support tickets forever.
How do I test that subscription cancellation revokes access?
Create a test subscription in Stripe test mode. Trigger `stripe trigger customer.subscription.deleted` with the Stripe CLI. Verify in your database that the subscription status updated to 'canceled'. Verify in your app that the user can no longer access premium features. This test takes 10 minutes and catches the most expensive class of Stripe integration bug.
My Bolt app's Stripe integration is broken. What now?
Free audit — we review your existing Stripe code, identify every missing webhook and gap, and tell you exactly what it'll take to fix. Integration Fix ($799) covers the full Stripe implementation end-to-end.
Next step

Fix your Bolt app's Stripe integration

We implement the full webhook surface, verify signature checking, sync subscription state to Supabase, and set up the customer portal. Integration Fix tier: $799. Free audit first.

Book free diagnostic →