Skip to content

Fix: Stripe Webhook Signature Verification Failed

FixDevs ·

Quick Answer

How to fix Stripe webhook signature verification errors — why Stripe-Signature header validation fails, how to correctly pass the raw request body, and how to debug webhook delivery in the Stripe dashboard.

The Error

Your Stripe webhook endpoint returns a 400 or 500 error with a message like:

No signatures found matching the expected signature for payload.

Or when using the Stripe SDK:

StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
    at module.exports.constructEvent (stripe/lib/Webhooks.js)

Or in Express logs:

Error: Webhook Error: No signatures found matching the expected signature for payload.

The Stripe dashboard shows the webhook delivery attempt failed with a non-2xx response, and Stripe retries the event — sometimes triggering duplicate processing.

Why This Happens

Stripe signs webhook payloads using HMAC-SHA256 with your webhook signing secret. The signature is sent in the Stripe-Signature header and computed against the raw, unmodified request body bytes.

Verification fails when:

  • The raw body has been parsed before verification — Express’s express.json() or body-parser middleware parses and re-serializes the JSON body, changing whitespace or key ordering, making the signature invalid.
  • Wrong signing secret — using the live mode secret in test mode (or vice versa), or using the API secret key instead of the webhook signing secret.
  • Multiple body-parser middlewares — one middleware parses the body globally, and the verification step receives the already-parsed object instead of a Buffer.
  • Proxy or load balancer modifying the body — some proxies compress or re-encode the request body.
  • Clock skew — Stripe’s signature includes a timestamp; if your server clock is off by more than 5 minutes (the default tolerance), verification rejects the event.
  • Using the wrong webhook secret — each webhook endpoint (test vs. live, each URL) has its own signing secret. Confirm you are using the secret for the specific endpoint.

Fix 1: Pass the Raw Buffer to constructEvent

The most common cause: express.json() is applied globally before the webhook route, converting the Buffer to a parsed object.

Broken — body already parsed when webhook handler runs:

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

// Applied globally — parses body for ALL routes including /webhook
app.use(express.json());

app.post('/webhook', (req, res) => {
  const sig = req.headers['stripe-signature'];

  try {
    // req.body is a parsed object, not a raw Buffer — verification fails
    const event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
    // ...
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
});

Fixed — use express.raw() for the webhook route, apply json() for others:

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

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

    try {
      const event = stripe.webhooks.constructEvent(
        req.body,   // Buffer ✓
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );

      // Handle the event
      switch (event.type) {
        case 'payment_intent.succeeded':
          handlePaymentSuccess(event.data.object);
          break;
        case 'customer.subscription.deleted':
          handleSubscriptionCanceled(event.data.object);
          break;
      }

      res.json({ received: true });
    } catch (err) {
      console.error('Webhook error:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
  }
);

// Apply json parser for all other routes AFTER the webhook route
app.use(express.json());
app.use('/api', apiRouter);

Why route order matters: Express applies middleware in the order it is registered. The webhook route registers its own express.raw() middleware before the global express.json() runs. If express.json() is registered first via app.use(), it runs before any route-specific middleware and parses the body into an object.

Fix 2: Verify You Are Using the Correct Webhook Secret

Each webhook endpoint has its own signing secret — it is not your Stripe API secret key.

Where to find the webhook signing secret:

  1. Go to Stripe Dashboard → Developers → Webhooks.
  2. Click your webhook endpoint.
  3. Under “Signing secret”, click “Reveal” to see the whsec_... prefixed secret.

Test mode vs. live mode: Stripe has separate webhook secrets for test mode and live mode. The signing secret for a test endpoint starts with whsec_test_ in some configurations. Confirm you are in the correct mode (look for “Test mode” in the dashboard header).

For local development — use the Stripe CLI:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/webhook

The CLI outputs a temporary signing secret:

> Ready! You are using Stripe API Version [2023-10-16]. Your webhook signing secret is whsec_abc123... (^C to quit)

Use this temporary secret (not the dashboard secret) for local development:

const endpointSecret = process.env.NODE_ENV === 'production'
  ? process.env.STRIPE_WEBHOOK_SECRET  // Dashboard secret
  : 'whsec_abc123...';  // CLI temporary secret — set via environment variable

Fix 3: Fix for Framework-Specific Body Parsing

Next.js API route:

Next.js parses request bodies by default. Disable it for the webhook route:

// pages/api/webhook.js or app/api/webhook/route.js

// Disable automatic body parsing for this route
export const config = {
  api: {
    bodyParser: false, // Required — let Stripe receive raw body
  },
};

// pages/api/webhook.js (Pages Router)
export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).end();
  }

  // Read raw body manually
  const rawBody = await new Promise((resolve, reject) => {
    const chunks = [];
    req.on('data', (chunk) => chunks.push(chunk));
    req.on('end', () => resolve(Buffer.concat(chunks)));
    req.on('error', reject);
  });

  const sig = req.headers['stripe-signature'];

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

Next.js App Router (route.js):

// app/api/webhook/route.js
import Stripe from 'stripe';

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

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

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

FastAPI (Python):

from fastapi import FastAPI, Request, HTTPException
import stripe
import os

app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
webhook_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/webhook")
async def stripe_webhook(request: Request):
    payload = await request.body()  # Raw bytes — not parsed
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, webhook_secret
        )
    except stripe.error.SignatureVerificationError as e:
        raise HTTPException(status_code=400, detail=str(e))

    # Handle the event
    if event["type"] == "payment_intent.succeeded":
        payment_intent = event["data"]["object"]
        handle_payment_success(payment_intent)

    return {"status": "success"}

Fix 4: Handle Webhook Events Idempotently

Stripe retries failed webhook deliveries — your handler may receive the same event multiple times. Always process events idempotently using the event ID:

// Track processed event IDs to prevent duplicate processing
const processedEvents = new Set(); // Use Redis or a database in production

app.post('/webhook', express.raw({ type: 'application/json' }), async (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) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency check — skip if already processed
  if (processedEvents.has(event.id)) {
    console.log(`Skipping duplicate event: ${event.id}`);
    return res.json({ received: true }); // Still return 200 — Stripe will stop retrying
  }

  try {
    await handleEvent(event);
    processedEvents.add(event.id);
    res.json({ received: true });
  } catch (err) {
    console.error(`Error processing event ${event.id}:`, err);
    // Return 500 to signal Stripe to retry
    res.status(500).json({ error: 'Processing failed' });
  }
});

// In production — store processed event IDs in a database
async function isEventProcessed(eventId) {
  const existing = await db.query(
    'SELECT id FROM processed_webhook_events WHERE event_id = $1',
    [eventId]
  );
  return existing.rows.length > 0;
}

async function markEventProcessed(eventId, eventType) {
  await db.query(
    'INSERT INTO processed_webhook_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING',
    [eventId, eventType]
  );
}

Fix 5: Return 200 Quickly, Process Asynchronously

Stripe expects a response within 30 seconds. For long-running processing, acknowledge immediately and process in the background:

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) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Respond immediately to Stripe — do not wait for processing
  res.json({ received: true });

  // Process asynchronously after responding
  setImmediate(async () => {
    try {
      await processStripeEvent(event);
    } catch (err) {
      console.error(`Failed to process event ${event.id}:`, err);
      // Log to an error tracking service — Stripe already got its 200
    }
  });
});

Warning: If you respond with 200 and then fail to process the event, Stripe will not retry — it considers the delivery successful. Either use a job queue with retry logic (BullMQ, Sidekiq, etc.) or respond with a non-2xx code if you cannot guarantee processing.

Fix 6: Debug Webhook Delivery in the Stripe Dashboard

Check failed webhook attempts:

  1. Stripe Dashboard → Developers → Webhooks → click your endpoint.
  2. Under “Recent deliveries”, find the failed attempt.
  3. Click the attempt to see the request headers, payload, and your response body.
  4. The response body from your endpoint is shown — check it for error details.

Re-send a specific event for testing:

# Using Stripe CLI
stripe events resend evt_1234567890abcdef

# Or from the dashboard: Developers → Events → find event → "Resend to endpoint"

Test webhook handling locally without live events:

# Trigger a specific event type via CLI
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

Still Not Working?

Check for a reverse proxy stripping or modifying headers. If your server is behind nginx, Cloudflare, or a load balancer, confirm the Stripe-Signature header is being forwarded unchanged. Some proxies strip non-standard headers.

Check your server’s clock. Stripe includes a timestamp in the signature. If your server clock is off by more than 5 minutes, verification fails:

# Check server time
date -u

# Sync clock via NTP
timedatectl set-ntp true

Disable tolerance for debugging only:

// Default tolerance is 300 seconds (5 minutes)
// Set to 0 to disable timestamp checking (testing only — never in production)
const event = stripe.webhooks.constructEvent(
  payload,
  sig,
  secret,
  0 // tolerance in seconds — 0 disables timestamp check
);

Verify the raw body with a debug log:

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  console.log('Content-Type:', req.headers['content-type']);
  console.log('Body type:', typeof req.body, Buffer.isBuffer(req.body));
  console.log('Body length:', req.body?.length);
  // Should log: "object", true, and a non-zero number
  // If you see "string" or a parsed object, your body parser middleware is running first
});

For related API integration issues, see Fix: CORS Access-Control-Allow-Origin Error and Fix: Express CORS Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles