The problem
Stripe webhooks are the most common payment integration failure in AI-built apps. The symptoms:
- Test payments succeed in Stripe dashboard but your database never updates
- Subscription status stays "pending" even after a user pays
checkout.session.completedevents show as "delivered" in Stripe dashboard but your handler never runs- You see
400or500responses in the Stripe webhook logs - The classic: it works with
stripe listen --forward-tolocally but breaks in production
All of these trace back to a small number of root causes that AI tools get wrong almost every time.
Why AI tools get this wrong
AI code generators know Stripe's API surface but don't know your deployment environment. They generate webhook handlers that:
- Miss signature verification entirely — or verify it incorrectly, causing all webhooks to return 400
- Parse the request body before Stripe's SDK can read it — Next.js body parsing strips the raw bytes needed for signature verification
- Register the wrong endpoint URL — dev vs production, missing
/apiprefix, wrong path - Handle only one event type — miss the 5 other events that actually matter for subscriptions
The fix
Step 1: Install and set up the Stripe SDK
npm install stripeYou need two Stripe keys in your environment:
# .env.local
STRIPE_SECRET_KEY=sk_test_... # Server-only, never exposed to client
STRIPE_WEBHOOK_SECRET=whsec_... # From your webhook endpoint config (next step)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # Safe for clientStep 2: Create the webhook endpoint in Stripe
In Stripe Dashboard → Developers → Webhooks → Add endpoint:
- Endpoint URL:
https://yourdomain.com/api/webhooks/stripe - Events to listen for (at minimum):
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
After saving, click Reveal signing secret and copy it to STRIPE_WEBHOOK_SECRET.
For local development, use the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripeThe CLI prints a webhook secret starting with whsec_ — use this as your local STRIPE_WEBHOOK_SECRET.
Step 3: Write the webhook handler correctly
The most important thing: you must use the raw request body for signature verification. Next.js's default body parser converts it to JSON first, which changes the byte representation and breaks the signature check.
Create app/api/webhooks/stripe/route.ts:
import { headers } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-06-30',
})
export async function POST(request: NextRequest) {
const body = await request.text() // ← Raw text, NOT request.json()
const headersList = await headers()
const signature = headersList.get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
}
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Signature verification failed'
console.error('Webhook signature verification failed:', message)
return NextResponse.json({ error: message }, { status: 400 })
}
// Handle events
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
await handleCheckoutCompleted(session)
break
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
await handleSubscriptionChange(subscription)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await handleSubscriptionCancelled(subscription)
break
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice
await handlePaymentSucceeded(invoice)
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
await handlePaymentFailed(invoice)
break
}
default:
// Acknowledge unknown events — don't return 4xx or Stripe will retry
console.log(`Unhandled event type: ${event.type}`)
}
} catch (err) {
console.error(`Error handling ${event.type}:`, err)
// Return 500 so Stripe retries — only do this for transient errors
return NextResponse.json(
{ error: 'Handler failed' },
{ status: 500 }
)
}
return NextResponse.json({ received: true })
}Step 4: Implement the handlers
Here's a complete example using Supabase, which is the most common database in Bolt/Loveable projects:
import { createClient } from '@supabase/supabase-js'
// Use service role key here — webhook runs server-side and needs to bypass RLS
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.mode === 'payment') {
// One-time payment — record it
await supabase.from('orders').insert({
stripe_session_id: session.id,
customer_email: session.customer_details?.email,
amount: session.amount_total,
status: 'paid',
metadata: session.metadata,
})
}
// For subscription mode, wait for customer.subscription.created instead
}
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string
// Look up the user by their Stripe customer ID
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('stripe_customer_id', customerId)
.single()
if (!profile) {
console.error(`No user found for Stripe customer: ${customerId}`)
return
}
await supabase.from('subscriptions').upsert({
user_id: profile.id,
stripe_subscription_id: subscription.id,
stripe_customer_id: customerId,
status: subscription.status,
price_id: subscription.items.data[0]?.price.id,
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
}, {
onConflict: 'stripe_subscription_id',
})
}
async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
await supabase
.from('subscriptions')
.update({ status: 'canceled' })
.eq('stripe_subscription_id', subscription.id)
}
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
// Update subscription status in case it was past_due
if (invoice.subscription) {
await supabase
.from('subscriptions')
.update({ status: 'active' })
.eq('stripe_subscription_id', invoice.subscription)
}
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
if (invoice.subscription) {
await supabase
.from('subscriptions')
.update({ status: 'past_due' })
.eq('stripe_subscription_id', invoice.subscription)
}
// TODO: send "payment failed" email to customer
}Step 5: Make your webhook route opt out of body parsing
In Next.js App Router, API routes handle their own body parsing and you read the raw body with request.text() as shown above. No extra config needed in App Router.
If you're on Pages Router (older Bolt projects sometimes use this), add:
// pages/api/webhooks/stripe.ts
export const config = {
api: {
bodyParser: false, // ← Critical for Pages Router
},
}Step 6: Make the route public
If you have middleware that requires authentication, the webhook route must be excluded. Stripe calls it without any auth headers.
In middleware.ts, add the webhook path to your public routes:
const PUBLIC_PATHS = [
'/login',
'/auth',
'/api/webhooks', // ← Add this
]
if (!user && !PUBLIC_PATHS.some(path => request.nextUrl.pathname.startsWith(path))) {
return NextResponse.redirect(new URL('/login', request.url))
}Verify it works
Local testing:
# Terminal 1 — run your app
npm run dev
# Terminal 2 — forward Stripe events
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Terminal 3 — trigger a test event
stripe trigger checkout.session.completedYou should see:
- The Stripe CLI show
200 OK - Your app's terminal show the event type being handled
- Your database updated
Production testing:
In Stripe Dashboard → Webhooks → your endpoint → Send test webhook. Start with checkout.session.completed. Watch the response code — 200 means it worked. 400 means signature verification failed (wrong secret). 500 means your handler threw an error (check your logs).
-
stripe trigger checkout.session.completedreturns 200 locally - Database row created/updated after trigger
- Production webhook shows 200 in Stripe dashboard
- Subscription status updates correctly after test checkout
Common mistakes
Mistake 1: Using request.json() instead of request.text()
// ❌ Breaks signature verification — body has already been parsed
const body = await request.json()
// ✅ Raw text for Stripe to verify
const body = await request.text()Mistake 2: Using the wrong webhook secret
Your Stripe dashboard has one signing secret per webhook endpoint. Your local Stripe CLI has a different one. If you paste the dashboard secret into .env.local while using the CLI, every webhook returns 400. Keep them separate:
# .env.local (development)
STRIPE_WEBHOOK_SECRET=whsec_abc123... # from stripe listen output
# .env.production (Vercel / production)
STRIPE_WEBHOOK_SECRET=whsec_xyz789... # from Stripe dashboard endpointMistake 3: Returning 4xx for events you don't handle
Stripe retries webhooks that return 4xx or 5xx. If you return 400 for unknown event types, Stripe will retry them repeatedly. Always return 200 for events you don't handle — you're acknowledging receipt, not saying you processed it.
Mistake 4: Not storing the Stripe customer ID on the user
You'll need to look up users by their Stripe customer ID in almost every webhook handler. If you didn't store stripe_customer_id on your profiles table when the customer was created, you have no way to link Stripe events to users. Add it during checkout:
// When creating a Stripe checkout session
const customer = await stripe.customers.create({ email: user.email })
await supabase
.from('profiles')
.update({ stripe_customer_id: customer.id })
.eq('id', user.id)
const session = await stripe.checkout.sessions.create({
customer: customer.id,
// ...
})Going further
- Set up Stripe's idempotency keys if retries could cause duplicate records
- Add webhook event deduplication — Stripe can deliver the same event more than once
- Monitor failed webhooks: Stripe retries for 3 days, but you want to know about failures faster than that
Payment integration is where most AI-built apps lose real money. If you'd rather have it done right, post a Payment Fix job on Smith.