Payments are the one part of a vibe-coded app where being wrong costs money in both directions — customers who pay and get nothing, or get everything without paying.
The good news: Stripe Checkout is extremely well documented, so AI reproduces it accurately. The bad news is entirely in the webhook, and that’s where every real failure lives.
Read this part of your code. It’s about eighty lines.
The Model
Three pieces:
- Checkout Session — you create one server-side, redirect the customer to Stripe, they pay on Stripe’s page. You never touch card details.
- Webhook — Stripe calls your server to say what happened. This is the source of truth.
- Your database — records what each user is entitled to.
The rule that prevents almost every problem: grant access in the webhook, never on the redirect.
Checkout
Add Stripe Checkout for a subscription with monthly and annual tiers. Create the Checkout Session in a server-side route. Pass the authenticated user’s ID as client_reference_id and store their Stripe customer ID on their user record. Redirect to /success on completion and /pricing on cancel. Use the official stripe Node library.
client_reference_id matters — it’s how the webhook knows which user paid. Without it you’re guessing from email addresses, which breaks the moment someone pays with a different one.
The Webhook — The Part That Goes Wrong
Here’s what AI typically writes:
export async function POST(req: Request) {
const body = await req.json() // ← no signature verification
if (body.type === 'checkout.session.completed') {
await grantAccess(body.data.object.client_reference_id)
}
return new Response('ok')
}
Two serious problems.
No signature verification. Anyone who finds this URL can POST fake JSON and grant themselves a subscription. It’s an unauthenticated endpoint that hands out paid access.
No idempotency. Stripe retries webhooks. The same event will arrive more than once, and this code processes it every time.
The correct version:
Rewrite the Stripe webhook properly. Verify the signature with stripe.webhooks.constructEvent using the raw request body and STRIPE_WEBHOOK_SECRET — do not parse JSON before verifying. Return 400 on verification failure. Store processed event IDs and skip duplicates. Handle checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted. Return 200 quickly and do slow work after.
The raw body detail is not pedantry. Signature verification hashes the exact bytes Stripe sent. If your framework parses JSON first, verification fails even when the request is genuine — and the usual “fix” is to disable verification.
In Next.js App Router:
const body = await req.text() // raw, not .json()
const sig = req.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
)
Never Trust the Redirect
This is the mistake that actually loses money:
// app/success/page.tsx — WRONG
await grantAccess(userId) // runs whenever someone loads /success
Anyone can visit /success. And a customer who closes the tab after paying never loads it — they paid and got nothing.
The success page confirms; the webhook grants.
The success page must only read subscription status from our database. It must never grant access. If the webhook hasn’t arrived yet, show a brief pending state that polls.
Subscriptions Aren’t One Event
A subscription has a lifecycle, and AI implementations usually handle only the first step.
| Event | What it means |
|---|---|
checkout.session.completed | First payment succeeded — grant access |
customer.subscription.updated | Plan changed, or renewed — sync status |
customer.subscription.deleted | Cancelled — revoke at period end |
invoice.payment_failed | Card declined — warn, start dunning |
Handle the full subscription lifecycle. Store status, current_period_end, and plan on the user record. On deletion, revoke access at period end rather than immediately — they paid for the remainder.
Testing
Install the Stripe CLI and forward webhooks locally:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
It prints a signing secret for local use. Then:
stripe trigger checkout.session.completed
Test cards: 4242 4242 4242 4242 succeeds, 4000 0000 0000 0341 attaches then fails on charge, 4000 0025 0000 3155 requires 3D Secure.
Walk the whole path before going live: subscribe, confirm access, cancel, confirm access persists to period end, confirm it’s revoked after.
Before You Go Live
- Webhook verifies signatures using the raw body
- Duplicate events skipped via stored event IDs
- Access granted only in the webhook, never on redirect
- Success page reads state, doesn’t grant it
- All four lifecycle events handled
- Cancellation revokes at period end
- Failed payments handled
- Live webhook endpoint registered in the Stripe dashboard
- Live keys in production env vars, never
NEXT_PUBLIC_ - Secret key not in Git history
- Amounts in the smallest currency unit (cents), verified server-side
- Prices read from Stripe, not from client input
That last one matters: if the client sends the price, someone will send a different one.
git log -p | grep -E "sk_live|sk_test|whsec_" | head
Should return nothing. If it doesn’t, roll the keys.
The Rule
Vibe code the pricing page, the upgrade flow, the billing UI, the emails. Read the webhook.
It’s the smallest file in your payment integration and the only one where a mistake is both silent and expensive.
Next: Vibe code a pricing page for the front end of this, or vibe code a SaaS MVP to see where payments fit in the whole build.
Related reading: