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
- A server-side Stripe Checkout session creation function. Typically lives in a route handler or serverless function and calls
stripe.checkout.sessions.create. - A client button or pricing card that fetches the above and redirects to the returned session URL.
- A success redirect page that reads the
session_idquery param and shows a confirmation UI. - Occasionally a cancel page that says “no charge was made.”
What Bolt does not generate
- A webhook handler endpoint.
- Signature verification of incoming webhook events using your webhook signing secret.
- Handlers for the specific event types that define your business state: subscription created, invoice paid, payment failed, subscription updated, subscription cancelled, trial ending.
- Idempotency logic so duplicate webhook deliveries do not double-apply their effects.
- A
subscriptionstable in your database that mirrors Stripe's state. - Access control in your app that reads from the subscriptions table to decide who can use paid features.
- The Customer Portal integration so users can manage their own card, plan, and cancellation.
- Testing infrastructure for verifying the full subscription lifecycle.
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:
- 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.
- Verify the signature using
stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET). The signature is in thestripe-signaturerequest header. If verification throws, return 400 and stop. - Dispatch on
event.typeto the appropriate handler function. Events you don't care about, log and return 200. - 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 processingAlternative: 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:
- In the Stripe dashboard, go to Settings → Billing → Customer portal.
- 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).
- Add the return URL — where users land when they click “back to the app.” Typically your dashboard.
- 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. - 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 loginForward 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:
stripe trigger checkout.session.completed— verify a subscription row appears and the user has access.stripe trigger invoice.payment_failed— verify the subscription moves topast_dueand your dunning logic fires.stripe trigger customer.subscription.deleted— verify the subscription is marked canceled and access is revoked immediately.stripe trigger customer.subscription.updated— verify a plan change flows through.
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.