# How to fix Stripe webhook signature error

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 15 minutes
- Compatibility: Stripe API v2024-12+, Node.js 18+, Express/Fastify/Next.js
- Last updated: March 2026

## TL;DR

Stripe webhook signature verification fails when the signing secret is wrong, the request body has been parsed or modified before verification, or there's significant clock skew between Stripe and your server. Fix it by using the correct webhook secret (whsec_), ensuring the raw request body reaches constructEvent, and keeping your server clock synchronized.

## Why Stripe Webhook Signature Verification Fails

Stripe signs every webhook event with an HMAC-SHA256 signature using your endpoint's signing secret. When you call stripe.webhooks.constructEvent(), it recomputes the signature from the raw body and compares it. If the signing secret is wrong, the body has been modified (e.g., parsed to JSON then re-stringified), or the timestamp is too far off, the verification fails. This is a critical security feature — never skip it.

## Before you start

- A Stripe account with a registered webhook endpoint
- Node.js 18+ with Express or another web framework
- Your webhook signing secret from the Stripe Dashboard or CLI

## Step-by-step guide

### 1. Use the correct webhook signing secret

Each webhook endpoint has its own unique signing secret (starts with whsec_). Find it in Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret. If you're testing locally with the Stripe CLI, use the whsec_ secret printed by 'stripe listen'.

**Expected result:** You have the correct whsec_ signing secret for your specific endpoint and environment.

### 2. Ensure the raw request body reaches constructEvent

The most common cause of signature failure is body parsing. If Express parses the body to JSON before your webhook handler runs, the re-stringified body won't match Stripe's signature. Use express.raw() for the webhook route.

```
const express = require('express');
const app = express();

// CORRECT: raw body for webhook route
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    try {
      const event = stripe.webhooks.constructEvent(
        req.body,  // This is a Buffer, not parsed JSON
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
      // Handle event...
      res.json({ received: true });
    } catch (err) {
      res.status(400).send(`Webhook Error: ${err.message}`);
    }
  }
);

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

**Expected result:** The webhook route receives the raw Buffer body, allowing constructEvent to correctly verify the signature.

### 3. Fix body parsing in Next.js API routes

Next.js automatically parses request bodies. You must disable body parsing for the webhook route and read the raw body manually.

```
// pages/api/webhook.js (Next.js Pages Router)
import Stripe from 'stripe';
import { buffer } from 'micro';

export const config = {
  api: {
    bodyParser: false,  // Disable Next.js body parsing
  },
};

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

export default async function handler(req, res) {
  const buf = await buffer(req);
  const sig = req.headers['stripe-signature'];

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

**Expected result:** Next.js API route receives the raw body buffer, and signature verification succeeds.

### 4. Handle clock skew

Stripe includes a timestamp in the signature. By default, constructEvent rejects events with timestamps more than 300 seconds (5 minutes) off from your server time. If your server clock is out of sync, either fix the clock or increase the tolerance.

```
// Increase tolerance to 600 seconds (only if your server clock drifts)
const event = stripe.webhooks.constructEvent(
  req.body,
  sig,
  endpointSecret,
  600  // tolerance in seconds
);
```

**Expected result:** Signature verification accounts for clock differences between Stripe and your server.

### 5. Test with the Stripe CLI

Use the Stripe CLI to send test webhook events to your local endpoint. The CLI provides its own signing secret that you must use during local testing.

```
# Start listening (note the whsec_ secret it prints)
stripe listen --forward-to localhost:3000/webhook

# In another terminal, trigger an event
stripe trigger payment_intent.succeeded
```

**Expected result:** The CLI forwards signed events to your local server. Signature verification passes using the CLI's whsec_ secret.

## Complete code example

File: `webhook-verified.js`

```javascript
require('dotenv').config();
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

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

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

    if (!endpointSecret) {
      console.error('STRIPE_WEBHOOK_SECRET not configured');
      return res.status(500).send('Webhook secret not configured');
    }

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

    console.log(`Received event: ${event.type} (${event.id})`);

    switch (event.type) {
      case 'payment_intent.succeeded':
        console.log('Payment succeeded:', event.data.object.id);
        break;
      case 'payment_intent.payment_failed':
        console.log('Payment failed:', event.data.object.id);
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

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

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

app.get('/', (req, res) => res.send('Webhook server running'));

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
```

## Common mistakes

- **Using the Dashboard signing secret with the Stripe CLI (or vice versa)** — undefined Fix: The CLI prints its own whsec_ secret when you run 'stripe listen'. Use that for local testing. Use the Dashboard secret for production.
- **Placing express.json() middleware before the webhook route** — undefined Fix: Put the webhook route with express.raw() BEFORE app.use(express.json()). Otherwise Express parses the body to JSON, destroying the raw bytes needed for verification.
- **Re-stringifying the parsed body for constructEvent** — undefined Fix: JSON.stringify(parsedBody) does not produce the exact same bytes as the original raw body. Whitespace and key ordering may differ. Always use the original raw body.
- **Copying the whsec_ secret with leading/trailing spaces** — undefined Fix: Trim your webhook secret. Even a single space character causes signature verification to fail.

## Best practices

- Always verify webhook signatures — never process unverified events
- Use express.raw() specifically for your webhook route to preserve the raw body
- Store the webhook signing secret in environment variables, not in code
- Use separate signing secrets for local (CLI) and production (Dashboard) environments
- Keep your server clock synchronized with NTP to avoid clock skew issues
- Log signature failures for debugging but never log the signing secret itself
- Return 200 quickly after verification — process events asynchronously if needed

## Frequently asked questions

### Where do I find my Stripe webhook signing secret?

In the Stripe Dashboard, go to Developers → Webhooks → click your endpoint → Signing secret → click to reveal. It starts with whsec_. For local testing with the CLI, the secret is printed when you run 'stripe listen'.

### Why does signature verification fail even with the correct secret?

The most common cause is body parsing. If your framework parses the body to JSON before constructEvent runs, the re-stringified body won't match. You must pass the raw, unparsed body bytes to constructEvent.

### Can I skip webhook signature verification?

You should never skip it in production. Without verification, anyone can send fake events to your endpoint. In development, you can temporarily disable it for debugging, but always re-enable it before deploying.

### What is clock skew in Stripe webhooks?

Stripe includes a timestamp in the webhook signature. If your server clock is off by more than 5 minutes, verification fails. Use NTP to keep your server clock accurate, or increase the tolerance parameter in constructEvent.

### Does each webhook endpoint have a different signing secret?

Yes. Each endpoint registered in the Stripe Dashboard has its own unique whsec_ signing secret. If you have multiple endpoints, make sure each uses its own secret.

### How do I debug webhook signature failures?

Log the type of req.body (should be Buffer, not object), verify the whsec_ secret matches your endpoint, check that no middleware modifies the body before your handler, and confirm your server clock is accurate.

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-fix-stripe-webhook-signature-error
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-fix-stripe-webhook-signature-error
