# How to fix Stripe webhook invalid signature error

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 20 minutes
- Compatibility: Stripe API v2024-12+, Express 4+, Next.js 13+, Fastify 4+
- Last updated: March 2026

## TL;DR

The Stripe webhook invalid signature error means the raw request body used for signature verification doesn't match what Stripe sent. This happens when your framework parses the body before constructEvent runs. Fix it by using framework-specific raw body handling: express.raw() for Express, bodyParser: false for Next.js, or rawBody for Fastify.

## Why Your Webhook Signature Is Invalid

Stripe computes an HMAC-SHA256 signature from the exact bytes of the request body it sends. When your web framework automatically parses the body into JSON, those original bytes are lost. Re-serializing the parsed object with JSON.stringify may produce different whitespace, key ordering, or Unicode escaping. The result: the recomputed signature doesn't match, and constructEvent throws an error. The fix is framework-specific — you need to capture the raw body before any parsing occurs.

## Before you start

- A Stripe webhook endpoint returning signature errors
- Node.js 18+ with your web framework (Express, Next.js, or Fastify)
- Your webhook signing secret (whsec_) from the Stripe Dashboard or CLI

## Step-by-step guide

### 1. Understand why parsed bodies break signatures

Stripe signs the exact bytes it sends. JSON.parse() then JSON.stringify() can change the byte sequence: key order may differ, Unicode escapes may be normalized, and whitespace may change. Even one byte difference means a completely different HMAC signature.

**Expected result:** You understand that the raw body bytes must be identical to what Stripe sent for the signature to match.

### 2. Fix for Express: use express.raw()

For Express, add express.raw({ type: 'application/json' }) to your webhook route. This stores the body as a Buffer instead of parsing it. Critical: this middleware must come before any express.json() call.

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

// Webhook route FIRST — raw body
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    const event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
    // req.body is a Buffer here
    res.json({ received: true });
  }
);

// JSON parsing for everything else AFTER
app.use(express.json());
```

**Expected result:** Express serves the raw Buffer body to the webhook handler, and signature verification passes.

### 3. Fix for Next.js Pages Router: disable bodyParser

Next.js API routes parse the body by default. Export a config object to disable it, then use the 'micro' package's buffer() function to read the raw body.

```
// pages/api/webhook.js
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, res) {
  const buf = await buffer(req);
  const sig = req.headers['stripe-signature'];

  const event = stripe.webhooks.constructEvent(
    buf, sig, process.env.STRIPE_WEBHOOK_SECRET
  );

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

**Expected result:** Next.js Pages Router API route reads the raw body with micro's buffer(), and signature verification passes.

### 4. Fix for Next.js App Router: use request.text()

In the Next.js App Router (route.js files), use the Web API's request.text() method to get the raw body as a string.

```
// app/api/webhook/route.js
import Stripe from 'stripe';
import { NextResponse } from 'next/server';

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

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

  const event = stripe.webhooks.constructEvent(
    body, sig, process.env.STRIPE_WEBHOOK_SECRET
  );

  // Handle event...
  return NextResponse.json({ received: true });
}
```

**Expected result:** Next.js App Router route handler gets the raw body string and verifies the signature correctly.

### 5. Fix for Fastify: use rawBody plugin

Fastify doesn't expose the raw body by default. Use the fastify-raw-body plugin or access request.rawBody after configuring the content type parser.

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

// Register raw body plugin
await fastify.register(require('fastify-raw-body'), {
  field: 'rawBody',
  global: false,
  encoding: false,
  runFirst: true,
});

fastify.post('/webhook', {
  config: { rawBody: true },
}, async (request, reply) => {
  const sig = request.headers['stripe-signature'];
  const event = stripe.webhooks.constructEvent(
    request.rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET
  );
  // Handle event...
  return { received: true };
});
```

**Expected result:** Fastify exposes the raw body via request.rawBody, and signature verification succeeds.

### 6. Debug signature mismatches

If you're still getting errors, add diagnostic logging to identify the root cause. Check the body type, length, and first few characters. Teams at RapidDev use this debugging approach when integrating Stripe webhooks for their clients.

```
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    console.log('Body type:', typeof req.body);
    console.log('Is Buffer:', Buffer.isBuffer(req.body));
    console.log('Body length:', req.body.length);
    console.log('First 100 chars:', req.body.toString().substring(0, 100));
    console.log('Signature header:', req.headers['stripe-signature']?.substring(0, 30));

    // Proceed with verification...
  }
);
```

**Expected result:** Diagnostic logs reveal whether the body is raw (Buffer) or parsed (object), helping you pinpoint the middleware issue.

## Complete code example

File: `webhook-raw-body.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 — 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) {
      return res.status(400).json({ error: 'Missing stripe-signature header' });
    }

    let event;
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
      console.error('Webhook signature failed:', err.message);
      console.error('Body type:', typeof req.body, 'Is Buffer:', Buffer.isBuffer(req.body));
      return res.status(400).json({ error: `Signature verification failed: ${err.message}` });
    }

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

    // Handle the event
    switch (event.type) {
      case 'payment_intent.succeeded':
        console.log('Payment succeeded:', event.data.object.amount);
        break;
      case 'invoice.payment_failed':
        console.log('Invoice payment failed:', event.data.object.id);
        break;
      default:
        console.log('Unhandled event:', event.type);
    }

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

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

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

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

## Common mistakes

- **Placing app.use(express.json()) before the webhook route** — undefined Fix: Move the webhook route with express.raw() ABOVE the global express.json() middleware. Express processes middleware in order.
- **Using JSON.stringify(req.body) as the body argument to constructEvent** — undefined Fix: Never re-serialize the body. Use the original raw body (Buffer or string). JSON.stringify produces different bytes than the original request.
- **Using request.json() instead of request.text() in Next.js App Router** — undefined Fix: request.json() parses the body. Use request.text() to get the raw string body for signature verification.
- **Not installing the micro package for Next.js Pages Router** — undefined Fix: Run npm install micro. The buffer() function from micro reads the raw body stream when Next.js body parsing is disabled.

## Best practices

- Always use framework-specific raw body handling — never rely on re-serialization
- Place the webhook route before any global body parsing middleware
- Add diagnostic logging during development to verify the body type is Buffer/string
- Test locally with the Stripe CLI to catch signature issues before deploying
- Store the webhook secret in environment variables with no extra whitespace
- Use separate webhook endpoints for different event categories when possible
- Monitor webhook delivery status in the Stripe Dashboard for production issues

## Frequently asked questions

### Why does JSON.stringify not work for Stripe webhook verification?

JSON.stringify may produce different bytes than the original body: key ordering, whitespace, and Unicode escaping can all differ. Stripe signs the exact bytes it sends, so even one byte difference breaks the signature.

### How do I get the raw body in Express?

Use express.raw({ type: 'application/json' }) as middleware on your webhook route. This gives you req.body as a Buffer instead of a parsed object. Place this route before any express.json() middleware.

### How do I get the raw body in Next.js App Router?

Use request.text() in your route handler (app/api/webhook/route.js). This returns the raw body as a string without JSON parsing.

### Do I need the micro package for Next.js?

For the Pages Router, yes — micro's buffer() function reads the raw body when bodyParser is disabled. For the App Router, use the built-in request.text() method instead.

### Can I use body-parser instead of express.raw()?

Yes, body-parser has a verify callback that stores the raw body: bodyParser.json({ verify: (req, res, buf) => { req.rawBody = buf; } }). Then use req.rawBody in constructEvent. However, express.raw() on just the webhook route is simpler and more explicit.

---

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