Stripe Troubleshooting

Stripe Webhook Signature Verification Failed: Common Causes and Fixes

Learn the most common causes of Stripe webhook signature verification failures and how to inspect the raw request before changing application code.

The “Stripe webhook signature verification failed” error means the HMAC signature your server computed from the incoming request body and your webhook signing secret does not match the signature Stripe included in the Stripe-Signature header. In almost every case the payload bytes, the signing secret, or the header value reaching your verification code is not what Stripe originally sent. This guide walks through every common cause and gives you a reliable debugging order so you can fix the failure in minutes instead of hours.

The error surfaces as a StripeSignatureVerificationError in Node.js, a SignatureVerificationError in Python, or an equivalent exception in other Stripe SDKs. Before you start changing application code, it is worth understanding the three exact inputs the verification function uses — and which one is wrong.

What this error actually means

Stripe protects webhook deliveries with HMAC-SHA256 signatures. When Stripe sends an event to your endpoint it performs three steps on its side:

  1. It takes the raw JSON body of the request.
  2. It prepends a timestamp: {timestamp}.{rawBody}.
  3. It computes HMAC-SHA256(signing_secret, timestampedPayload) and places the result in the Stripe-Signature header as a v1 signature.

Your server repeats the same computation with the signing secret you have stored. If the two signatures match, the request is authentic and unmodified. If they do not match, the SDK throws a signature verification error.

This means only three things can cause the error:

  • The signing secret your code uses is wrong.
  • The raw body your code passes to the verification function is different from what Stripe sent.
  • The Stripe-Signature header was altered or lost before your code read it.

Every debugging step below maps back to one of these three inputs.

Most common causes

1. Wrong webhook secret

This is the single most frequent cause. Stripe generates a unique whsec_... secret for every webhook endpoint you register. If you have separate endpoints for development and production, or for test mode and live mode, each has its own secret.

Common mistakes:

  • Copying the secret from the wrong endpoint row in the Stripe Dashboard.
  • Using the Stripe API key (sk_test_... or sk_live_...) instead of the webhook signing secret.
  • Having a stale secret in your environment after rotating it in the Dashboard.
  • Reading the secret from a .env file that was not reloaded after a change.

Go to Stripe Dashboard > Developers > Webhooks, click the endpoint that matches your URL, and reveal the signing secret. Compare it character by character with the value your application reads at runtime.

2. Raw body modified before verification

Stripe signs the exact bytes of the HTTP request body. If anything changes those bytes before the verification function runs, the computed signature will differ. Common modifications include:

  • A JSON body parser converting the body into an object and then re-serializing it. Even if the JSON is semantically identical, key order, whitespace, or Unicode escape sequences may differ.
  • A reverse proxy or API gateway decompressing, re-encoding, or normalizing the body.
  • Character encoding conversions (e.g., Latin-1 to UTF-8) applied by the platform.

The fix is always the same: pass the verification function the exact bytes that arrived over the wire, before any processing.

3. Middleware parsed body too early

This is the most common variant of the raw body problem in Node.js applications. In Express, calling app.use(express.json()) globally parses every incoming request body into a JavaScript object. By the time your webhook route handler runs, req.body is already an object. Passing JSON.stringify(req.body) to stripe.webhooks.constructEvent() produces different bytes than the original payload.

The same issue appears in Next.js API routes (which parse the body by default), Fastify (which has built-in JSON parsing), and most serverless platforms. The solution for each framework is covered in the framework-specific fixes section below.

4. Test/live mode mixed

Stripe maintains completely separate environments for test mode and live mode. Each has its own webhook endpoints, signing secrets, and event streams. If you registered an endpoint in test mode but your code uses the live mode signing secret — or vice versa — verification will always fail.

Check these three things:

  • The mode toggle in the top-right corner of the Stripe Dashboard when you view the endpoint.
  • The prefix of the signing secret: whsec_... does not indicate mode, so you must confirm it belongs to the correct endpoint.
  • The event data itself: test mode events use test IDs like evt_test_....

5. Stripe-Signature header missing or wrong

Some infrastructure components strip or rename non-standard HTTP headers. Load balancers, CDN edge functions, and API gateways are common culprits. If the Stripe-Signature header is missing or truncated when it reaches your code, verification cannot succeed.

Verify that:

  • Your reverse proxy or load balancer forwards all headers. In Nginx, check that proxy_pass_request_headers is on (the default).
  • No middleware in your application stack drops or renames the header.
  • You are reading the header with the correct casing for your framework. HTTP headers are case-insensitive in the spec, but some frameworks expose them in a specific format (e.g., request.headers['stripe-signature'] in Express).

6. Different endpoint than expected

If you have multiple webhook endpoints configured in Stripe — for example, one for development, one for staging, and one for production — a request may arrive at a URL whose handler uses a different endpoint’s secret. This commonly happens when:

  • An old endpoint is still active after a URL migration.
  • A development endpoint using Stripe CLI forwarding overlaps with a deployed endpoint.
  • A wildcard route catches requests intended for a different handler.

Audit the list of active endpoints in your Stripe Dashboard and disable any that are no longer needed.

Step-by-step debugging process

Step 1 — Confirm the request arrived

Before debugging signatures, confirm the request actually reached your server. If the request never arrived, the problem is delivery, not verification. Use a webhook debugger to capture incoming requests independently of your application. This gives you a clean copy of the headers and body without any middleware interference.

If you are testing locally, make sure your tunnel (ngrok, Cloudflare Tunnel, or Stripe CLI) is running and forwarding to the correct local port.

Step 2 — Inspect headers and raw payload

Look at the Stripe-Signature header value and the full request body as they arrive at your server. Log them before any processing:

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  const rawBody = req.body; // Buffer

  console.log('Stripe-Signature:', sig);
  console.log('Raw body length:', rawBody.length);
  console.log('Raw body preview:', rawBody.toString('utf8').substring(0, 200));

  // proceed with verification...
});

If the Stripe-Signature header is missing or the body is an object instead of a Buffer, you have found the problem.

Step 3 — Double-check the webhook secret

Go to Stripe Dashboard > Developers > Webhooks, find the endpoint that matches the URL receiving the request, and reveal the signing secret. Compare it with the value your application reads:

// Log a safe prefix to confirm the right secret is loaded
console.log('Webhook secret prefix:', process.env.STRIPE_WEBHOOK_SECRET?.substring(0, 12));

Make sure the environment variable is loaded correctly. In Docker containers and serverless platforms, environment variables set during build time may differ from those available at runtime.

Step 4 — Review framework request parsing

Check whether your framework automatically parses the request body before your webhook handler runs.

Express example — move express.json() so it does not apply to the webhook route:

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();

// Webhook route MUST come before express.json() middleware
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error('Signature verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Handle the event
    switch (event.type) {
      case 'checkout.session.completed':
        // Fulfill the order
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

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

// JSON parsing for all other routes
app.use(express.json());

app.listen(3000, () => console.log('Server running on port 3000'));

Next.js App Router example — read the raw body from the request:

// app/api/webhook/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';

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

export async function POST(request: NextRequest) {
  const sig = request.headers.get('stripe-signature')!;
  const rawBody = await request.text();

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    console.error('Signature verification failed:', err.message);
    return NextResponse.json(
      { error: `Webhook Error: ${err.message}` },
      { status: 400 }
    );
  }

  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object;
      // Fulfill the order using session data
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

Step 5 — Retry with a saved request

After applying a fix, replay the exact same request that previously failed rather than triggering a new Stripe event. This confirms your fix works for the specific payload that caused the error. You can replay Stripe webhook events locally using saved request data from the Stripe Dashboard or a webhook debugging tool.

Replaying also saves time: you do not have to recreate test scenarios (creating charges, completing checkouts) every time you want to test a verification fix.

Framework-specific fixes

Express

The key is to ensure the webhook route receives the raw request body as a Buffer. Use express.raw() on the specific route and place it before any global express.json() middleware:

const app = express();

// Option A: Route-level raw body (recommended)
app.post('/webhook', express.raw({ type: 'application/json' }), handleWebhook);

// Option B: Global JSON parsing with raw body preserved via verify callback
app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf;
    },
  })
);

Option A is cleaner because the webhook route never sees a parsed body. Option B is useful when you need both parsed and raw bodies in the same route.

Next.js

In the Pages Router, disable body parsing for the webhook API route:

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

export const config = {
  api: {
    bodyParser: false,
  },
};

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

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const sig = req.headers['stripe-signature'] as string;
  const rawBody = await buffer(req);

  try {
    const event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
    // Handle event...
    res.status(200).json({ received: true });
  } catch (err: any) {
    res.status(400).send(`Webhook Error: ${err.message}`);
  }
}

In the App Router (Next.js 13+), use request.text() to get the raw body as shown in the Step 4 example above. The App Router does not auto-parse the body when you read it with .text().

Python / Flask

Flask provides the raw body through request.data or request.get_data(). Make sure you call this before accessing request.json, which triggers parsing:

import stripe
from flask import Flask, request, jsonify

app = Flask(__name__)
stripe.api_key = os.environ['STRIPE_SECRET_KEY']

@app.route('/webhook', methods=['POST'])
def webhook():
    sig = request.headers.get('Stripe-Signature')
    raw_body = request.get_data()

    try:
        event = stripe.Webhook.construct_event(
            raw_body, sig, os.environ['STRIPE_WEBHOOK_SECRET']
        )
    except stripe.error.SignatureVerificationError:
        return jsonify({'error': 'Invalid signature'}), 400

    # Handle event...
    return jsonify({'received': True}), 200

In Django, use request.body which gives you the raw bytes before any middleware processing.

Quick checklist

Use this checklist to systematically rule out each cause:

  • The request actually reached your endpoint (confirmed via logs or a webhook debugger)
  • The Stripe-Signature header is present, complete, and not truncated
  • The webhook signing secret matches the exact endpoint and mode (test vs. live) in Stripe Dashboard
  • You are passing the raw, unparsed request body (Buffer or bytes) to the verification function
  • No global middleware (like express.json()) parses the body before your webhook route runs
  • Test mode and live mode are not mixed between the endpoint registration and your signing secret
  • The URL in Stripe Dashboard matches the exact URL your server is listening on
  • After fixing, you replayed the same failing request to confirm the fix

Frequently asked questions

Why does signature verification fail only in my local development environment?

Local setups typically differ from production in several ways. Your .env file might contain a different signing secret. If you use Stripe CLI for local forwarding, it generates its own signing secret (visible in the CLI output as whsec_...) that differs from the one in the Stripe Dashboard. You need to use the CLI-provided secret when testing locally. Additionally, local tunneling tools can occasionally modify headers or re-encode request bodies.

Can I skip signature verification during development?

Technically yes, but it is strongly recommended to keep verification enabled at all times. Skipping it means your development environment does not match production, which can hide bugs. If verification is too cumbersome locally, use Stripe CLI forwarding with its provided signing secret — this gives you a working verification flow without needing to expose your server to the internet.

What if verification worked yesterday but fails today?

Three things to check immediately: whether the webhook signing secret was rotated in the Stripe Dashboard (Stripe allows you to rotate secrets, and the old one stops working after the rotation grace period expires), whether a new deployment changed your middleware stack or body parsing configuration, and whether you accidentally switched between Stripe test mode and live mode. Also check if a dependency update changed how your framework handles request bodies.

Does the Stripe-Signature header expire?

Yes. Stripe includes a timestamp in the signature header, and the SDK’s verification function rejects signatures older than a default tolerance (usually 300 seconds / 5 minutes). If your server processes the webhook with significant delay — for example, if requests queue up behind a slow handler — the timestamp check may fail even though the HMAC is correct. You can increase the tolerance by passing a tolerance parameter, but this weakens replay attack protection. A better fix is to verify the signature immediately and process the event asynchronously.

How do I handle webhook secret rotation without downtime?

When you rotate a signing secret in Stripe, there is a brief period where both the old and new secrets are valid. During this window, update your application to try verification with the new secret first, and fall back to the old secret if it fails. Once the grace period expires, remove the old secret from your configuration. Some teams store both secrets as environment variables (STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_SECRET_OLD) and try each in sequence.

Next steps

Once signature verification is working, you can move on to building reliable webhook handling:

  • Set up a complete Stripe webhook testing workflow to catch verification issues before they reach production.
  • Learn how to replay Stripe webhook events locally so you can debug with real payloads without triggering new events.
  • Read the full Stripe integration guide for end-to-end setup including endpoint creation, event filtering, and monitoring.
  • Explore the webhook debugger to inspect incoming requests, compare headers and payloads, and diagnose delivery issues across all your webhook providers.
  • Browse more Stripe webhook guides covering testing, signature verification, and event replay.