Nurture TechnologiesNurture Tech
Integrations12 min read·September 5, 2026

Stripe Webhook Signature Verification Failing

StripeNode.jsExpressNext.js

stripe.webhooks.constructEvent() throws 'No signatures found matching the expected signature' because Express or Next.js has already parsed the raw request body into a JSON object before verification runs. The signature was computed on the original bytes not the re-serialised JSON.

Problem Summary

Stripe signs every webhook payload with a HMAC-SHA256 signature computed on the raw request bytes. When your server verifies the signature using stripe.webhooks.constructEvent(), it recomputes the signature from the body you pass it and compares the result against the Stripe-Signature header. If the bytes do not match exactly, verification fails.

The most common cause by a large margin is that a body-parsing middleware has already transformed the raw bytes into a JavaScript object before constructEvent() runs. Even if you then pass JSON.stringify(req.body) back to constructEvent(), the re-serialised string is not byte-for-byte identical to the original, and verification fails.

Symptoms

  • constructEvent() throws: Error: No signatures found matching the expected signature for payload
  • Webhook endpoint returns 400 to Stripe, which retries the event repeatedly
  • Verification works in the Stripe CLI (stripe listen) locally but fails in production
  • Verification fails only after adding express.json() globally to the app
  • The Stripe-Signature header is present in logs but verification still fails

The exact error from the Stripe SDK: 'No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? If a webhook integration tool is being used, make sure it is not parsing the payload as a string.'

Root Cause

Stripe computes the webhook signature as HMAC-SHA256(timestamp + "." + raw_body_bytes, endpoint_secret). The raw body is the exact byte sequence Stripe sent over the wire. When Express parses the body with express.json(), the Buffer is deserialised to a JavaScript object. Even though the data is identical, JSON.stringify() may produce a different byte sequence different key order, different whitespace, different encoding of special characters so the HMAC no longer matches.

Fix for Express.js

Route order matters. The Stripe webhook route must be declared before any global express.json() middleware, or it must use express.raw() specifically for that route.

Wrong global JSON parser runs before verification

app.js
// ❌ Global JSON parser converts raw bytes to object
app.use(express.json());

app.post('/webhooks/stripe', (req, res) => {
  const sig = req.headers['stripe-signature'];
  // req.body is now a parsed object, not the original bytes
  const event = stripe.webhooks.constructEvent(req.body, sig, secret);
  // Throws: No signatures found matching the expected signature
});

Correct use express.raw() for the Stripe route only

app.js
// ✓ Stripe webhook route uses raw body parser for this route only
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

    try {
      // req.body is now a Buffer  exactly what Stripe sent
      event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
    } catch (err) {
      console.error('Webhook verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Handle the event
    switch (event.type) {
      case 'payment_intent.succeeded':
        // handle payment
        break;
    }

    res.json({ received: true });
  }
);

// Other routes use JSON parsing as normal
app.use(express.json());
app.use('/api', apiRoutes);

If you must declare express.json() before the Stripe route (for example because of framework structure), skip JSON parsing for the webhook path specifically: app.use((req, res, next) => { if (req.path === '/webhooks/stripe') return next(); express.json()(req, res, next); });

Fix for Next.js App Router

In the App Router, use request.text() to get the raw body string. Never call request.json() on a webhook route.

app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  // ✓ Raw text  not request.json()
  const body = await request.text();
  const sig = request.headers.get('stripe-signature');

  if (!sig) {
    return new Response('Missing stripe-signature header', { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    return new Response(`Webhook Error: ${err.message}`, { status: 400 });
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent;
      // handle payment
      break;
  }

  return new Response(JSON.stringify({ received: true }), {
    headers: { 'Content-Type': 'application/json' },
  });
}

Fix for Next.js Pages Router

The Pages Router auto-parses the body by default. Disable it for the webhook route by exporting a config object.

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

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// ✓ Disable automatic body parsing  required for Stripe webhooks
export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    res.setHeader('Allow', 'POST');
    return res.status(405).end('Method Not Allowed');
  }

  // Read the raw buffer using 'micro'
  const buf = await buffer(req);
  const sig = req.headers['stripe-signature'];

  if (!sig) {
    return res.status(400).json({ error: 'Missing stripe-signature header' });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(buf, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err: any) {
    return res.status(400).json({ error: err.message });
  }

  // Process event
  res.json({ received: true });
}

Other Causes to Check

Wrong endpoint secret

Each Stripe webhook endpoint has its own signing secret it is not your Stripe secret key or publishable key. It starts with whsec_. You find it in the Stripe Dashboard under Developers > Webhooks > click the endpoint > Signing secret. If you have separate endpoints for test mode and live mode, they have different secrets.

$echo $STRIPE_WEBHOOK_SECRET | head -c 10 # Should start with: whsec_
  • Check you copied the webhook signing secret, not the Stripe secret key (sk_live_ or sk_test_)
  • Verify the secret in your .env matches the specific endpoint in the Stripe dashboard
  • If you have multiple webhook endpoints (one for test, one for live), confirm you are using the correct secret for the environment receiving the event
  • If you rotated the secret in the dashboard, update the environment variable in your hosting platform and restart the server

Stripe-Signature timestamp too old

Stripe includes a timestamp in the Stripe-Signature header (t=...) and by default the SDK rejects events where the timestamp is more than 300 seconds (5 minutes) old. This prevents replay attacks. If your server clock is out of sync with NTP, or if you are processing a webhook event from a queue with high latency, verification will fail even with the correct body and secret.

// Increase the tolerance window if processing from a queue
// Default is 300 seconds (5 minutes)
const event = stripe.webhooks.constructEvent(
  body,
  sig,
  process.env.STRIPE_WEBHOOK_SECRET,
  600  // 10 minutes tolerance
);

Verify Your Fix Works

Test using the Stripe CLI before deploying. The CLI forwards real Stripe events to your local server and shows you exactly what Stripe sends and what your server returns.

$stripe listen --forward-to localhost:3000/webhooks/stripe
$stripe trigger payment_intent.succeeded

If the CLI shows a 200 response, your verification is working. If it shows 400 with the signature error, the raw body problem is not yet fixed.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

Why does Stripe webhook verification work locally but fail in production?+

The most common reason is that a body-parsing middleware (like express.json()) is applied globally in production configuration but not in local development, or the order of middleware differs between environments. Check that your Stripe webhook route receives the raw body Buffer in both environments, not a parsed JavaScript object.

What is the STRIPE_WEBHOOK_SECRET and where do I find it?+

It is the signing secret for a specific webhook endpoint in the Stripe dashboard. Go to Developers > Webhooks, click on your endpoint, and click 'Reveal' next to Signing secret. It starts with whsec_. It is different from your Stripe secret key (sk_live_ or sk_test_) and must be stored separately in your environment variables.

Can I verify Stripe webhooks without the raw body?+

No. The HMAC-SHA256 signature is computed on the exact raw bytes of the request body. There is no way to reconstruct the original bytes from a parsed JSON object reliably. You must preserve the raw body from the incoming HTTP request before any parsing middleware touches it.

Should I return 200 before processing the event to prevent retries?+

Yes, for long-running processing. Return a 200 immediately to acknowledge receipt, then process the event asynchronously. If you return a 200 only after processing completes and processing takes more than a few seconds, Stripe may time out and retry the event, causing duplicate processing. Acknowledge first, queue the work, process asynchronously.

How do I test Stripe webhooks locally?+

Use the Stripe CLI: run 'stripe listen --forward-to localhost:3000/webhooks/stripe' to forward live events to your local server. Then use 'stripe trigger payment_intent.succeeded' to send a test event. The CLI shows the full request and response so you can see exactly what is being sent and received.