Guide
How to verify Stripe webhooks in Next.js, and the other payment mistakes to check
Verify the signature, grant access only from the webhook, handle each event once, and never take the price from the browser.
Your webhook URL is public. Anyone who finds it can send a request that looks exactly like a Stripe event. These four checks stop fake payments, free upgrades and double credits.
1. Verify the signature
// Bad: trusts whatever was posted
export async function POST(req: Request) {
const event = await req.json();
if (event.type === "checkout.session.completed") await grantPro(event.data.object);
return new Response("ok");
}
// Good: app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // the raw body: req.json() breaks the signature check
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") await grantPro(event.data.object);
return new Response("ok");
}
Without the check, anyone can post a fake "checkout completed" event and give themselves your paid plan.
2. Grant access in the webhook, not on the success page
// Bad: app/success/page.tsx
await supabaseAdmin.from("profiles").update({ plan: "pro" }).eq("id", user.id);
The success page should only say "Thanks, your account will update in a moment." Anyone can visit /success without paying. Grant access in the verified webhook, and find the user with the client_reference_id you set when creating the Checkout Session.
3. Handle each event once
Stripe retries webhooks and can deliver the same event more than once. Make the effect itself impossible to apply twice, by recording it under a unique key in the same insert that grants it:
create table public.credit_grants (
stripe_session_id text primary key,
user_id uuid not null references auth.users (id),
credits integer not null,
created_at timestamptz not null default now()
);
alter table public.credit_grants enable row level security; -- no policies: server only
const session = event.data.object as Stripe.Checkout.Session;
const { error } = await supabaseAdmin.from("credit_grants").insert({
stripe_session_id: session.id,
user_id: session.client_reference_id,
credits: 100,
});
if (error?.code === "23505") return new Response("already processed"); // duplicate key
if (error) return new Response("Try again", { status: 500 }); // Stripe will retry
A user's balance is then the sum of their grants. Avoid recording "event seen" before doing the work: if the work then fails, Stripe's retry is skipped and the payment is lost.
4. Never take the price from the browser
// Bad
const { priceInPence, plan } = await req.json();
await stripe.checkout.sessions.create({
line_items: [{ price_data: { currency: "gbp", unit_amount: priceInPence, product_data: { name: plan } }, quantity: 1 }],
mode: "payment", success_url, cancel_url,
});
// Good
import { z } from "zod";
const PRICES = { pro: process.env.STRIPE_PRICE_PRO!, team: process.env.STRIPE_PRICE_TEAM! };
const { plan } = z.object({ plan: z.enum(["pro", "team"]) }).parse(await req.json());
await stripe.checkout.sessions.create({
line_items: [{ price: PRICES[plan], quantity: 1 }],
mode: "subscription",
client_reference_id: user.id,
success_url, cancel_url,
});
Anything the browser sends can be edited, so the bad version lets someone buy your plan for pennies.
Checking it automatically
Seaworthy flags webhook handlers that act on Stripe events without verifying them (SW-P01) and amounts or prices taken from the request (SW-P02). It doesn't check sections 2 and 3 yet. Run it with:
npx seaworthy-cli
These fixes reduce common risks. They are not a security audit or a guarantee.