Stripe: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body." (Stripe docs). In Next.js, how you get that raw body depends on the router.
Read the body with await req.text() and pass that string to constructEvent. Next.js documents request.text() for webhooks and notes that "unlike API Routes with the Pages Router, you do not need to use bodyParser to use any additional configuration" (Next.js docs). This is the shape of Stripe's own App Router example (Stripe example):
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
export async function POST(req: Request) {
const body = await req.text() // NOT req.json()
const signature = req.headers.get('stripe-signature') as string
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET as string)
} catch (err) {
return Response.json({ message: 'Webhook Error' }, { status: 400 })
}
// handle event.type here
return Response.json({ received: true }, { status: 200 })
}
The mistake to avoid: calling await req.json() and then JSON.stringify-ing it back. Re-serialising changes the bytes, and "any manipulation to the raw body of the request causes the verification to fail" (source).
API Routes parse the body automatically. Next.js: "bodyParser is automatically enabled", and one reason to disable it is "to verify the raw body of a webhook request" (Next.js docs). Stripe's troubleshooting table says the same for the Pages Router: disable bodyParser and read a buffer (Stripe docs; Stripe example):
// pages/api/webhooks/stripe.ts
import Stripe from 'stripe'
import type { NextApiRequest, NextApiResponse } from 'next'
export const config = { api: { bodyParser: false } }
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
const buffer = (req: NextApiRequest) =>
new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); return res.status(405).end() }
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(await buffer(req), req.headers['stripe-signature'] as string, process.env.STRIPE_WEBHOOK_SECRET as string)
} catch (err) {
return res.status(400).send('Webhook Error')
}
// handle event.type here
res.json({ received: true })
}
stripe listen secret only matches events forwarded by the CLI, and test and live endpoints have different secrets (source).Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkUse const body = await req.text() in the route handler and pass that string to stripe.webhooks.constructEvent. No bodyParser config is needed in the App Router.
Export config = { api: { bodyParser: false } } from the API route, read the request stream into a Buffer, and pass the Buffer to constructEvent.
Stripe signs the exact bytes it sent. Parsing and re-serialising the JSON changes those bytes, so the signature no longer matches.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-10-18. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.