Two separate problems, and you probably have both.
1. You're re-serializing the body. req.json() followed by JSON.stringify() does not give you the bytes Stripe signed (key order, whitespace and unicode escaping can differ). Use the raw text:
export async function POST(req: Request) {
const body = await req.text();
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 (err) {
return new Response("bad signature", { status: 400 });
}
// handle event...
return new Response("ok");
}That export const config = { api: { bodyParser: false } } is a Pages Router thing. It does nothing in the App Router, delete it.
2. The secret is different per endpoint. The whsec_... that stripe listen prints is only for the CLI. Your deployed endpoint (Developers > Webhooks > your endpoint > Signing secret) has its own. Put that one in Vercel and redeploy (env changes need a new deployment).
It "worked" locally because the CLI secret matched and the payload happened to survive the stringify round trip.
stripe listenprinted or the one from the Dashboard endpoint page? tobiasw · editedstripe listen... is that different? rosa_m · edited