Tillping
Menu

Fix guides

Stripe webhook signature verification failed

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.

Check 1: Is it the right secret?

  • The secret starts with whsec_. It is not your API key (sk_...) and not the endpoint ID (we_...).
  • The secret from stripe listen on your laptop is different from the secret of an endpoint you made in the Stripe Dashboard. Don't mix them.
  • Test mode and live mode use different secrets. When you go live, copy the live endpoint's secret.
  • To find it: in Stripe, open the endpoint and click Reveal secret.
  • Look for a space or line break at the end of the value in your environment variables.
  • Changed the value on your host? Deploy again. Many hosts only load new values on a new deploy.

Check 2: Is the body untouched?

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):

// 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.

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.

// 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 constructEventAsync with Stripe.createSubtleCryptoProvider(). See our Supabase 401 guide for the full code.

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.

Check 3: Is the header right?

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.

Tip: a "too old" error

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.

Check it with a free scan

Your check should refuse requests with a bad signature. Paste your webhook address into the free Tillping scan: https://tillping.online/#scan and pick Stripe in the provider menu. Only scan an address you own or are allowed to test.

It sends one POST with no signature and one with a fake Stripe signature. If your endpoint says OK (a 2xx) to either one, the scan warns you. A 400, 401, 403 or 422 is the right answer.

This scan can't find the problem on this page: real events failing the check. It never asks for your secret, so it can't tell if your secret or raw body is right. Use Checks 1 to 3 and a test event from Stripe for that.

Sources

Run the free check

Paste your webhook address and see what your endpoint answers. No account. No keys.

Check my webhook