Stripe webhooks in the Next.js App Router: the 5 mistakes AI agents make
Billing is where vibe-coded SaaS apps most often lose money or give away access. These are the five mistakes we see AI agents make most, and the fixes.
1. Parsing JSON before verifying
Signature verification needs the raw body. In an App Router route handler, use await req.text(), never req.json().
export const runtime = "nodejs";
export async function POST(req: Request) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const sig = req.headers.get("stripe-signature");
if (!sig) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// handle event.type ...
}
2. Creating the Stripe client at module top level
Top-level new Stripe(process.env.STRIPE_SECRET_KEY!) runs when Next collects page data at build time, so builds can fail in CI where the secret isn't set. Create SDK clients inside the handler.
3. Granting access on the success page
Anyone can visit /success?session_id=.... Treat the webhook as the only source of truth, and flip the user's plan when checkout.session.completed arrives, but only after re-checking Stripe for a currently active subscription on your price. A completed checkout can still be unpaid, and a free trial isn't a payment. Store each event id so a retried webhook isn't applied twice, and downgrade on canceled, past_due or unpaid.
4. Not linking the session to a user
Pass client_reference_id: user.id when creating the Checkout Session on the server, then store session.customer on that user's profile. Later subscription events can then be matched by customer ID.
5. Forgetting cancellations
Handle customer.subscription.updated and customer.subscription.deleted too, or users keep Pro forever. Upsert by Stripe IDs so retried events are harmless.
Test locally
stripe listen --forward-to localhost:3000/api/stripe/webhook
# then run a real test-mode checkout with card 4242 4242 4242 4242stripe trigger checkout.session.completed sends a fake event with no user attached, so it is good for checking the signature path but will not upgrade anyone.
The full, build-tested version, with Supabase writes through a server-only admin client, is in Pack #1.