Guides / Stripe webhook signature verification

last checked oct 2026

Stripe webhook signature verification: fixing "No signatures found" and checking yours is on

Your Stripe webhook is the thing that tells your app "this person paid." If it doesn't check the Stripe-Signature header, anyone who finds the URL can send a fake event and your app will believe it.

The short answer

Every webhook handler should pass three things to Stripe's library: the raw request body, the Stripe-Signature header, and that endpoint's whsec_ signing secret. In Node that's stripe.webhooks.constructEvent(). If it throws, return a 400 and do nothing else.

If you're seeing No signatures found matching the expected signature for payload, Stripe's docs say one of those three inputs is wrong. Usually it's the wrong secret (the Stripe CLI and the Dashboard endpoint each have their own) or a framework that parsed the JSON before your code got the raw body.

Don't "fix" that error by deleting the check. That's how AI-generated handlers end up trusting any POST request that claims to be a checkout.session.completed event.

How to check

1. Find your handler and see whether it verifies

Run this in your project root. It lists the files that handle Stripe events and the files that verify signatures. Every file in the first list should also be in the second:

# files that act on Stripe events
grep -rlE "checkout\.session\.completed|payment_intent\.succeeded|invoice\.paid|customer\.subscription" \
  --include=*.ts --include=*.js --include=*.py . 2>/dev/null | grep -v node_modules

# files that verify the signature
grep -rlE "constructEvent|construct_event|constructEventAsync" \
  --include=*.ts --include=*.js --include=*.py . 2>/dev/null | grep -v node_modules

Red flags inside a handler: JSON.parse(body) or await req.json() used to read the event, the signature check wrapped in an if that's skipped when the secret is missing, or a catch that logs the error and then carries on.

2. Send your own endpoint an unsigned request

Only do this against an app you own. This posts a made-up event type with no signature. It prints just the status code:

curl -s -o /dev/null -w '%{http_code}\n' -X POST https://your-app.com/api/webhooks \
  -H 'Content-Type: application/json' \
  -d '{"id":"evt_test_unsigned","object":"event","type":"lockedvibe.unsigned_test","data":{"object":{}}}'

You want a 400. If you get a 200, your endpoint accepted an event nobody signed, so verification is missing or being skipped.

3. Confirm real events still pass

With the Stripe CLI, forward test events to your local server and fire one:

stripe listen --forward-to localhost:3000/api/webhooks
# in a second terminal
stripe trigger payment_intent.succeeded

stripe listen prints its own whsec_ secret when it starts. Use that one while testing locally. Your deployed endpoint uses the secret shown on its page in the Stripe Dashboard, and test mode and live mode have different secrets too.

To see which kind of secret your app has loaded without printing it, this shows only the prefix and the length:

node -e 'const s = process.env.STRIPE_WEBHOOK_SECRET || ""; console.log(s.slice(0, 6) || "(not set)", s.length)'

It should print whsec_. If it prints sk_liv, sk_tes or rk_, you've put your API key where the signing secret belongs.

How to fix it

Next.js App Router (v0, Vercel, most Cursor apps)

Read the body with req.text(), not req.json(). This follows Stripe's own example:

// app/api/webhooks/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() // raw body, unparsed
  const signature = req.headers.get('stripe-signature')
  const secret = process.env.STRIPE_WEBHOOK_SECRET
  if (!signature || !secret) {
    return new Response('Missing signature or secret', { status: 400 })
  }

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, secret)
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    // look up event.id first so a repeat delivery isn't processed twice
  }
  return new Response('ok', { status: 200 })
}

If the secret is missing, the handler refuses the request. Failing open ("no secret, so skip the check") is the bug this guide is about.

Next.js Pages Router

Pages API routes parse JSON bodies by default, so turn that off for this route and read the raw bytes yourself:

// pages/api/webhooks.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
export const config = { api: { bodyParser: false } }

async function rawBody(req: NextApiRequest) {
  const chunks: Buffer[] = []
  for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
  return Buffer.concat(chunks)
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end()
  try {
    const event = stripe.webhooks.constructEvent(
      await rawBody(req),
      req.headers['stripe-signature'] as string,
      process.env.STRIPE_WEBHOOK_SECRET as string
    )
    // handle event.type here
    return res.status(200).json({ received: true })
  } catch {
    return res.status(400).send('Invalid signature')
  }
}

Express

Give the webhook route express.raw() and keep express.json() from running on it first. Stripe's docs point out that Express runs middleware in order, so a global app.use(express.json()) above the route breaks verification:

// webhook route first, with the raw body
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    )
  } catch {
    return res.status(400).send('Invalid signature')
  }
  // handle event.type here
  res.sendStatus(200)
})

// JSON parsing for every other route
app.use(express.json())

Supabase Edge Functions (Lovable, Bolt)

Edge Functions run on Deno, which uses the Web Crypto API, so use the async version with a crypto provider. This follows the approach in Supabase's Stripe webhook example:

import Stripe from 'npm:stripe'

const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY') as string)
const cryptoProvider = Stripe.createSubtleCryptoProvider()

Deno.serve(async (req) => {
  const signature = req.headers.get('Stripe-Signature')
  const body = await req.text() // raw body
  let event
  try {
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature!,
      Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!,
      undefined,
      cryptoProvider
    )
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }
  // handle event.type here
  return Response.json({ ok: true })
})

Stripe can't send a Supabase user JWT, so this function is deployed with JWT verification off (verify_jwt = false in the Supabase example). That makes the signature check the only thing guarding it, so it can't be skipped.

Three details that matter after it works

The signing secret is a server-only value like your secret API key. If it was ever in front-end code, our guide to rotating exposed API keys covers the order to do things in.

next step

Check your live app for free

Our free Leak Checker looks at your deployed app the way any visitor's browser does and flags exposed secret keys, public source maps and missing security headers. It's a quick way to make sure no Stripe secret or other key shipped to the browser.

If you'd rather have the whole app looked at, the $99 Scan + Report covers your repo and live app, including payment webhooks, Supabase RLS, leaked keys and auth checks.

Sources

Every product step above was checked against these official pages. Last checked Oct 2026. Products change; if a menu moved, the docs below are the source of truth.