# Stripe webhook signature verification failed: 3 things to check

> Source: <https://dev.to/tillping-hq/stripe-webhook-signature-verification-failed-3-things-to-check-174o>
> Published: 2026-11-02 13:16:39+00:00

*This article was created with the help of AI. Each step was checked against the Stripe docs and the stripe-node library listed at the end.*

Here's why this error matters. A customer pays, Stripe sends your app a webhook, and your app turns it away because the signature check fails. The customer paid and gets nothing. Stripe keeps retrying for a while, but you may not notice until someone writes in, upset. The good news: the cause is almost always one of three small things.

**The error:**

```
Webhook signature verification failed.
No signatures found matching the expected signature for payload.
```

**Short answer:** Stripe's check uses three things: the raw request body, the `Stripe-Signature` header and your endpoint's signing secret. If any one of them is wrong, the check fails. Stripe says the most common cause is the **wrong secret**.

`whsec_`. It is `sk_...`) and `we_...`).` stripe listen` on your laptop is Stripe signs the exact bytes it sends. If your app turns the body into JSON and back, the bytes change and the check fails. Get the **raw** body.

**Next.js (App Router):**

``` python
// 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() // raw text. Do not use req.json()
  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(`Webhook Error: ${(err as Error).message}`, { status: 400 })
  }

  // handle event.type here
  return Response.json({ received: true })
}
```

**Express:** put the raw parser on the webhook route, and add `express.json()` **after** it.

``` js
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret)
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`)
  }
  // handle event.type here
  res.json({ received: true })
})

app.use(express.json()) // other routes only
```

**Next.js (Pages Router):** turn off the body parser for this route and read the raw bytes.

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

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

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()
  const sig = req.headers['stripe-signature'] as string
  try {
    const event = stripe.webhooks.constructEvent(await rawBody(req), sig, process.env.STRIPE_WEBHOOK_SECRET!)
    // handle event.type here
    return res.json({ received: true })
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
  }
}
```

**Supabase Edge Functions (Deno):** use `await req.text()`, and use the async version with Stripe's crypto provider:

``` js
const cryptoProvider = Stripe.createSubtleCryptoProvider()
const event = await stripe.webhooks.constructEventAsync(body, signature, secret, undefined, cryptoProvider)
```

The plain `constructEvent` fails there with "SubtleCryptoProvider cannot be used in a synchronous context". Supabase also blocks Stripe with a 401 by default until you set `verify_jwt = false` for the webhook function.

Something in front of your app can also change the body, such as a proxy or a forwarding tool. Even a small change, like extra spaces in the JSON, breaks the check.

Print the `Stripe-Signature` header once. It should look like `t=...,v1=...`. If it is empty, your code is reading the wrong header, or something in between removed it.

Stripe's libraries also reject events whose timestamp is more than 5 minutes off from your server's clock (the default). If you replay a saved request later, it will fail. Send a fresh test event instead, and make sure your server's clock is correct.

Once real events pass, check the other side: your endpoint must say no to a bad signature. Try it on your own endpoint:

```
curl -i -X POST https://yourapp.com/api/webhooks/stripe \
  -H 'Content-Type: application/json' \
  -H 'Stripe-Signature: t=1,v1=bad' \
  -d '{}'
```

You want a 4xx, such as 400. If you get a 2xx, your route is not checking the signature.

`constructEvent`, `constructEventAsync`): More plain-English webhook fix guides: [https://tillping.online/fix?ref=dev](https://tillping.online/fix?ref=dev)

Tillping is a small tool that checks the payment-webhook path of your app, so a paying customer isn't left with nothing while you hear nothing. It isn't open yet. If you'd like a heads-up when it is, join the early-bird list: [https://tillping.online/?ref=dev](https://tillping.online/?ref=dev)
