Symptoms
- A route named
webhookreadsreq.bodyand switches onevent.typewith noconstructEvent. - Client code contains
fetch('/api/webhook')after checkout, sometimes with{ type: 'checkout.session.completed' }. - Users show as paid when they close the tab, or when they never paid.
- Stripe dashboard → Developers → Webhooks shows no endpoint, or an endpoint that 500s, and the app still “works.”
Why Lovable / Bolt / v0 do this
The generator knows checkout needs a success path. Calling your own webhook from the browser is fewer moving parts than a raw-body server route, a signing secret, and a dashboard URL. It works in preview because you are the only user and you are honest. In production it is a button that says “set is_paid = true.”
The other failure: a real webhook route that JSON-parses the body first. constructEvent then throws every time, so someone comments it out to “unblock launch.”
The actual fix
Stripe’s servers POST to you. Your browser does not.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
export const config = { api: { bodyParser: false } } // Pages router
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')
const raw = Buffer.from(await req.arrayBuffer())
let event
try {
event = stripe.webhooks.constructEvent(
raw,
sig,
process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
return new Response('bad signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
// mark paid by session.id / client_reference_id — server only
}
return Response.json({ received: true })
}
- Put
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETin Vercel server env. NotVITE_. - Stripe dashboard → Webhooks → add
https://yourdomain.com/api/webhook→ copy thewhsec_. - Checkout
success_urlcan land on a “thanks, polling…” page. That page reads the order row from your API, which reads the database the webhook updated. - Delete any
fetch('/api/webhook')fromsrc/.
whsec_ will 400 forever, which is safer than a handler with no check, and looks like an outage. Match the keys to the mode.A copy-paste stub is in the kit at stripe/webhook.ts. The $297 triage is this issue, end to end, including the dashboard clicks.