Stripe webhook signature verification in Next.js
In a Next.js App Router route handler, read the body with await req.text(), pass that exact string, the Stripe-Signature header and your endpoint’s whsec_ secret to stripe.webhooks.constructEvent(), and return 400 if it throws. Then record each event.id you have handled so a retried delivery doesn’t grant access or ship an order twice. Most broken handlers get one of three things wrong: they parse the body before verifying it, they never verify at all, or they process the same event more than once.
On this page9 sections
- Why does Stripe sign webhook events?
- A verified webhook route for the App Router
- Breakage 1: the body was parsed before verification
- Where to find your Stripe webhook secret
- Breakage 2: no signature check at all
- Breakage 3: the same event processed twice
- A failing-then-passing test for your webhook route
- What else to check around Stripe webhooks
- Sources
Why does Stripe sign webhook events?
Stripe signs every event so your server can tell a real Stripe delivery from a request someone else sent to the same URL. Your webhook route is a public POST endpoint, and Stripe’s docs warn that without verification “an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records.”
The signature arrives in the Stripe-Signature header and looks like t=...,v1=.... The t value is a timestamp that is part of the signed payload, so it can’t be changed without breaking the signature. Stripe’s official libraries reject events whose timestamp is more than 5 minutes off by default, which limits replays of a captured request. Stripe’s docs also say not to set that tolerance to 0, because a value of 0 turns the recency check off.
Stripe also publishes the IP addresses it sends webhooks from and recommends allowlisting them alongside signature checks. The allowlist helps, but the signature check is what proves the body came from Stripe unmodified.
A verified webhook route for the App Router
A working App Router handler reads the raw text body, verifies it, and only then looks at the event. Here is an example adapted from the pattern in stripe-node’s own Next.js example:
// app/api/webhooks/stripe/route.ts (example)
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
import { claimEvent } from '@/lib/stripe-events'
import { fulfillCheckout } from '@/lib/fulfill'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET
if (!secret) return new NextResponse('Webhook secret not set', { status: 500 })
const body = await req.text() // raw string, never req.json()
const signature = req.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, secret)
} catch {
return new NextResponse('Invalid signature', { status: 400 })
}
// Skip events we already handled, and still return 200 so Stripe stops retrying.
if (!(await claimEvent(event.id))) {
return NextResponse.json({ received: true, duplicate: true })
}
if (event.type === 'checkout.session.completed') {
await fulfillCheckout(event.data.object as Stripe.Checkout.Session)
}
return NextResponse.json({ received: true })
}The handler needs STRIPE_WEBHOOK_SECRET and STRIPE_SECRET_KEY as plain server-side environment variables. Next.js exposes any variable prefixed with NEXT_PUBLIC_ to the browser, so neither of these should ever carry that prefix. The sibling article on Stripe keys in client JavaScript covers how to check your bundle for that.
Breakage 1: the body was parsed before verification
Verification fails when your code hands constructEvent() anything other than the exact bytes Stripe sent. Stripe’s docs say the body “must be the exact UTF-8 string Stripe sent, without any modifications,” and that frameworks which reorder keys, strip whitespace or convert to JSON cause verification to fail.
In the App Router this usually happens in one line: const data = await req.json() followed by JSON.stringify(data) passed into constructEvent(). The re-serialized string can differ from the original in whitespace or key order, the computed signature no longer matches, and you get the familiar error:
No signatures found matching the expected signature for payload.The fix is to call req.text() once and pass that string. Next.js route handlers use the standard Web Request API, so text() is available without extra setup. If you still have a Pages Router API route, Stripe’s docs say to disable bodyParser and read the buffer yourself; that is a different code path from the one above.
When the body is fine and the error persists, Stripe says the most common cause is the wrong endpoint secret. That leads to the next section.
Where to find your Stripe webhook secret
Your webhook secret is the whsec_ value tied to one specific endpoint. For an endpoint you created in the Dashboard, open it in Workbench under Webhooks and click Reveal secret. For local testing with the Stripe CLI, the secret is printed in the terminal when you run stripe listen.
These are two different secrets. Stripe’s troubleshooting page says not to mix them up, and recommends printing the endpointSecret your code uses and comparing it with the Dashboard or CLI value. The secret belongs to the endpoint, so a value copied from stripe listen or from another endpoint into production will fail verification on every event that endpoint receives.
Stripe recommends rolling the secret periodically or when you suspect it leaked. When you roll it in Workbench, you can keep the old secret active for up to 24 hours while you update your server, and Stripe signs each event once per active secret during that window.
Breakage 2: no signature check at all
The worst version of a Stripe webhook route reads the event straight from the JSON body and acts on it. Anyone who finds the URL can then post a hand-written checkout.session.completed event and get whatever your handler grants for it.
A route can end up like this during prototyping, with verification commented out “until the secret is set up,” or when generated code reads await req.json() and switches on event.type with no constructEvent() call anywhere. Nothing breaks in testing, because Stripe’s real events have the same shape as forged ones.
A quick way to check your own code: search the repository for constructEvent. If your webhook route doesn’t call it (or constructEventAsync) before reading event.type, treat it as unverified. Also check that the catch block returns a 4xx and stops. A try/catch that logs the error and carries on processing is the same as no check.
Breakage 3: the same event processed twice
Stripe can deliver the same event more than once, and a handler that isn’t built for that will fulfill an order or extend a subscription twice. Stripe’s docs say endpoints “might occasionally receive the same event more than once” and recommend logging processed event IDs and skipping ones you’ve already seen.
Retries make duplicates routine. In live mode Stripe retries undelivered events for up to three days with exponential backoff, and every retry gets a new timestamp and signature, so a valid signature doesn’t mean a first delivery. Events can also arrive out of order, and Stripe says not to use the created timestamp to decide whether you’ve already processed an event.
One way to dedupe is a table keyed on the event ID. In Postgres (including Supabase), INSERT ... ON CONFLICT DO NOTHING RETURNING returns a row only when it inserted one:
-- example schema
create table stripe_events (
id text primary key, -- evt_...
received_at timestamptz not null default now()
);// lib/stripe-events.ts (example, using node-postgres)
import { pool } from '@/lib/db'
export async function claimEvent(eventId: string): Promise<boolean> {
const { rows } = await pool.query(
'insert into stripe_events (id) values ($1) on conflict (id) do nothing returning id',
[eventId],
)
return rows.length === 1 // false means another delivery already claimed it
}If you claim the event first and fulfillment then throws, the retry will be skipped, so either do both in one transaction or delete the claim when fulfillment fails. And Stripe notes that in some cases it generates two separate Event objects for the same change; for those, key on the object ID in data.object plus event.type rather than the event ID alone.
Stripe also asks endpoints to return a 2xx quickly, before any slow logic that could time out. For heavier work, verify, claim the event, put it on a queue and return 200.
A failing-then-passing test for your webhook route
You can test signature handling without calling Stripe, because stripe-node ships stripe.webhooks.generateTestHeaderString() for signing a payload locally. The tests below are an example written for the handler above (Vitest syntax); adapt the import paths to your project.
// app/api/webhooks/stripe/route.test.ts (example)
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Stripe from 'stripe'
const SECRET = 'whsec_test_secret' // illustrative test value
process.env.STRIPE_WEBHOOK_SECRET = SECRET
process.env.STRIPE_SECRET_KEY = 'sk_test_illustrative'
const seen = new Set<string>()
vi.mock('@/lib/stripe-events', () => ({
claimEvent: async (id: string) => (seen.has(id) ? false : (seen.add(id), true)),
}))
const fulfillCheckout = vi.fn()
vi.mock('@/lib/fulfill', () => ({ fulfillCheckout }))
const { POST } = await import('./route')
const stripe = new Stripe('sk_test_illustrative')
const payload = JSON.stringify(
{ id: 'evt_test_1', object: 'event', type: 'checkout.session.completed',
data: { object: { id: 'cs_test_1', object: 'checkout.session' } } },
null, 2, // pretty-printed, so re-serializing changes the bytes
)
function post(body: string, signature?: string) {
return POST(new Request('http://localhost/api/webhooks/stripe', {
method: 'POST', body,
headers: signature ? { 'stripe-signature': signature } : {},
}))
}
beforeEach(() => { seen.clear(); fulfillCheckout.mockClear() })
describe('stripe webhook', () => {
it('rejects an unsigned event', async () => {
expect((await post(payload)).status).toBe(400)
expect(fulfillCheckout).not.toHaveBeenCalled()
})
it('rejects a body that was re-serialized after signing', async () => {
const sig = stripe.webhooks.generateTestHeaderString({ payload, secret: SECRET })
const reserialized = JSON.stringify(JSON.parse(payload))
expect((await post(reserialized, sig)).status).toBe(400)
})
it('accepts a signed event and fulfills once across two deliveries', async () => {
const sig = stripe.webhooks.generateTestHeaderString({ payload, secret: SECRET })
expect((await post(payload, sig)).status).toBe(200)
expect((await post(payload, sig)).status).toBe(200)
expect(fulfillCheckout).toHaveBeenCalledTimes(1)
})
})Run these against a handler that uses req.json() and has no dedupe, and the first and third tests fail: the forged event is accepted and fulfillment runs twice. Switch to the handler above and all three pass. That before-and-after is a cheap way to prove the fix to yourself and to anyone reviewing the pull request.
Once the unit tests pass, run stripe listen --forward-to localhost:3000/api/webhooks/stripe and trigger a test event to check the real round trip with the CLI’s secret.
What else to check around Stripe webhooks
A verified handler can still grant the wrong thing. A few checks that sit next to signature verification:
- Look up prices and entitlements on the server from your own records or from Stripe, and never from amounts or plan names the browser sent at checkout.
- If you run auth in
proxy.ts(formerlymiddleware.ts), confirm its matcher doesn’t redirect the webhook path to a login page. Stripe’s request carries no session cookie. - Make sure the route’s error responses don’t echo internal details back to the caller.
If you want a second pair of eyes on this, the Launch Lockdown review covers Stripe webhooks, idempotency and server-side pricing, and delivers fixes as a tested pull request. The free check is passive only and needs written permission from the app’s owner, so it can’t test a webhook route, but it does look for keys exposed in your public bundle.
Sources
- Stripe Docs, “Receive Stripe events in your webhook endpoint”: https://docs.stripe.com/webhooks (accessed 2026-11-07)
- Stripe Docs, “Manage webhook endpoints” (signature verification errors, raw body, secrets, rolling): https://docs.stripe.com/webhooks/signature (accessed 2026-11-07)
- stripe-node README, “Webhook signing” and “Testing Webhook signing”: https://github.com/stripe/stripe-node/blob/master/README.md (accessed 2026-11-07)
- stripe-node Next.js App Router webhook example: https://github.com/stripe/stripe-node/blob/master/examples/webhook-signing/nextjs/app/api/webhooks/route.ts (accessed 2026-11-07)
- Next.js Docs, “route.js” (Route Handlers): https://nextjs.org/docs/app/api-reference/file-conventions/route (accessed 2026-11-07)
- Next.js Docs, “How to think about data security in Next.js” (
NEXT_PUBLIC_exposure): https://nextjs.org/docs/app/guides/data-security (accessed 2026-11-07) - Next.js Docs, “proxy.js” (middleware renamed to proxy): https://nextjs.org/docs/app/api-reference/file-conventions/proxy (accessed 2026-11-07)
- PostgreSQL Docs, “INSERT” (ON CONFLICT DO NOTHING, RETURNING): https://www.postgresql.org/docs/current/sql-insert.html (accessed 2026-11-07)