Ask an AI coding assistant to "add Stripe subscriptions" and you will usually get a checkout button that works and a webhook route that looks fine. The checkout is the easy half. The webhook is where real money and real access get decided, and it is where generated code most often cuts corners.
The common problems are always the same few:
- The route parses the body as JSON before verifying the signature, so verification fails, and the "fix" is to remove verification.
- There is no protection against Stripe sending the same event twice.
- The code assumes events arrive in order.
- The route sits behind auth middleware, so Stripe's requests get redirected to a login page.
- Errors are swallowed and a 200 is returned, so Stripe never retries a failed update.
This post walks through a webhook route for the Next.js App Router that handles each of these, plus a way to test it locally before you trust it.
1. The route: read the raw body, then verify
Stripe signs the exact bytes it sends. If anything parses and re-serialises the body first, the signature no longer matches. In the App Router you get the raw body with req.text(). You don't need the old bodyParser: false config from the Pages Router.
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
export const runtime = "nodejs"; // the Stripe Node SDK expects the Node runtime
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Server-only client. The service-role / secret key bypasses RLS,
// so it must never be imported into client code.
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new NextResponse("Missing signature", { status: 400 });
}
const body = await req.text(); // raw body, unparsed
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new NextResponse("Invalid signature", { status: 400 });
}
// ...idempotency and handling below
return NextResponse.json({ received: true });
}
Two details matter here:
-
STRIPE_WEBHOOK_SECRETis thewhsec_...signing secret for this endpoint. It is not your API key. The Stripe CLI gives you a different one from the Dashboard endpoint, and mixing them up is a common reason for "signature verification failed". - If verification fails, return 400 and stop. Never fall back to "trust the payload anyway".
2. Keep the webhook out of your auth middleware
If you protect routes in middleware.ts (or proxy.ts in newer Next.js versions), check that the matcher doesn't catch /api/stripe/webhook. Stripe isn't logged in. If your middleware redirects unauthenticated requests, Stripe gets a 3xx or an HTML login page and records a failure.
export const config = {
matcher: ["/((?!api/stripe/webhook|_next/static|_next/image|favicon.ico).*)"],
};
The signature is the authentication for this route. Nothing else is needed.
3. Handle duplicate events (idempotency)
Stripe may deliver the same event more than once, and retries happen whenever your endpoint times out or returns an error. If your handler "adds 100 credits" or "sends a welcome email", a duplicate does real damage.
The simplest guard is a table keyed on the event ID:
create table if not exists public.stripe_events (
id text primary key, -- Stripe event id, e.g. evt_...
type text not null,
received_at timestamptz not null default now()
);
alter table public.stripe_events enable row level security;
-- No policies: only the server-side service-role client touches this table.
Then claim the event before doing any work:
const { error: claimError } = await supabase
.from("stripe_events")
.insert({ id: event.id, type: event.type });
if (claimError) {
// 23505 = unique violation: we've already processed this event
if (claimError.code === "23505") {
return NextResponse.json({ received: true, duplicate: true });
}
return new NextResponse("DB error", { status: 500 });
}
try {
await handleEvent(event);
} catch (err) {
// Release the claim so Stripe's retry can process it again
await supabase.from("stripe_events").delete().eq("id", event.id);
console.error("Webhook handling failed", event.id, err);
return new NextResponse("Handler error", { status: 500 });
}
The release step matters. If you claim the event, crash halfway, and still keep the row, the retry will be skipped as a "duplicate" and the customer never gets access.
Where you can, also make the writes themselves idempotent. "Set this user's plan to pro" is safe to run twice. "Increment credits by 100" isn't.
4. Don't trust event order
Stripe doesn't guarantee that events arrive in the order they happened. You can receive customer.subscription.updated before checkout.session.completed, or an old update after a newer one.
A robust pattern: treat the event as a notification, then fetch the current state from Stripe and write that.
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.client_reference_id; // set when creating the session
if (!userId || !session.subscription) return;
const sub = await stripe.subscriptions.retrieve(
session.subscription as string
);
await upsertSubscription(userId, sub);
return;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const incoming = event.data.object as Stripe.Subscription;
const sub = await stripe.subscriptions.retrieve(incoming.id); // latest state
const userId = await findUserIdByCustomer(sub.customer as string);
if (userId) await upsertSubscription(userId, sub);
return;
}
default:
return; // ignore events you didn't ask for
}
}
async function upsertSubscription(userId: string, sub: Stripe.Subscription) {
const { error } = await supabase.from("subscriptions").upsert(
{
user_id: userId,
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
status: sub.status,
price_id: sub.items.data[0]?.price.id ?? null,
updated_at: new Date().toISOString(),
},
{ onConflict: "user_id" }
);
if (error) throw error;
}
Because you always write the latest state from Stripe, a late or repeated event can't roll a user back to an old status.
findUserIdByCustomer is a lookup on your subscriptions table by stripe_customer_id. Write that mapping on checkout. To link a checkout to a user, pass client_reference_id: user.id when you create the Checkout Session on the server, using the user ID from the server-side session. Never use a value the browser sends you.
5. Gate access on your database, not the redirect
Another pattern AI tools produce: the success page (/billing/success?session_id=...) flips the user to "pro". Anyone can visit that URL. Access should come only from the row your webhook wrote:
const { data } = await supabase
.from("subscriptions")
.select("status")
.eq("user_id", user.id)
.maybeSingle();
const isPro = data?.status === "active" || data?.status === "trialing";
Your subscriptions table should have RLS on, with at most a select policy so users can read their own row. Users should never be able to insert or update it. Only the webhook (service role) writes.
6. Test it locally before you trust it
The Stripe CLI forwards real, signed test events to your machine:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the whsec_... it prints into STRIPE_WEBHOOK_SECRET in .env.local
# in another terminal
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
Then run the negative tests, which most people skip:
# No signature: expect 400
curl -i -X POST localhost:3000/api/stripe/webhook -d '{}'
# Forged signature: expect 400
curl -i -X POST localhost:3000/api/stripe/webhook \
-H "stripe-signature: t=1,v1=deadbeef" -d '{"type":"checkout.session.completed"}'
If either of those returns 200, your route is trusting unsigned input. Fix that before launch.
Finally, test a duplicate. Find a delivered event in the Stripe Dashboard and resend it, then confirm nothing changes twice in your database.
A short checklist
- [ ] Raw body via
req.text(), verified withconstructEvent - [ ] Endpoint-specific
whsec_secret, separate for CLI and production - [ ] Webhook path excluded from auth middleware
- [ ] Event-ID table with a unique key, released on failure
- [ ] Writes are idempotent; current state fetched from Stripe
- [ ] User linked via server-set
client_reference_id - [ ] Access read from your DB row, not the success URL
- [ ]
subscriptionstable: RLS on, users can't write it - [ ] Unsigned and forged requests return 400
- [ ] Errors return 5xx so Stripe retries
If you'd rather start with this already wired
We packaged this setup, along with Supabase auth with RLS on every table and a server-side Gemini route, as a Next.js 16 starter: AI SaaS Starter Template (Next.js 16 + Supabase + Stripe + Gemini). It's for people who'd rather spend the first week on their product than on plumbing. Everything above works fine without it.
Need the whole app built for you instead? That's what we do at verdanopulence.digital/services.
Top comments (1)
Fetching the latest subscription from Stripe instead of trusting the payload is the right fix for out-of-order events. One edge I'd check in upsertSubscription: with onConflict on user_id there's a single row per user, but the event you retrieve is for whichever subscription id fired. If someone cancels an old subscription and starts a new one (or has a trial plus a paid one for a while), a late customer.subscription.deleted for the old id fetches that old sub, status canceled, and overwrites the row that was just set to active. "Latest state" is then latest for that subscription, not for the user.
Keying the row on stripe_subscription_id, or ignoring events whose subscription id doesn't match the one stored for the user, would close it. Might be a non-issue if your plans only allow one subscription per customer, but I'd want a test with a resend of an old deleted event after a resubscribe.