Skip to content

Fix: Stripe Webhook Signature Verification Failed

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

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.

In Production: Incident Lens

How the incident surfaces. Signature failures are a payment-flow incident, and that is the single most expensive class of bug a small team can ship. The Stripe Dashboard delivery log fills with non-2xx responses, your endpoint logs No signatures found matching, and the events that should have moved orders forward sit unprocessed. The user-facing symptom depends on which event you missed: missed payment_intent.succeeded means the order is paid but stuck in “pending payment,” missed customer.subscription.deleted means a churned customer keeps their access, missed invoice.payment_failed means a dunning email never fires. The customer support inbox fills faster than the error rate dashboard updates.

Blast radius. The first hour usually buys you grace because Stripe auto-retries with exponential backoff up to about 3 days, so the queue absorbs short outages. The damage compounds when the bug is silent: the endpoint returns 500 on a body-parser misconfig that ships with a Friday deploy, the on-call notices Monday morning, and you have a 72-hour backlog of events to replay. Revenue impact is bounded by Stripe’s retry window but operational impact (manual reconciliation, support tickets, accounting fixes) scales linearly with the gap.

The monitoring signal that catches it. Stripe Dashboard → Webhooks → endpoint detail page is the source of truth — alert on the “Success rate (last 24h)” dropping below 99% via the Stripe webhook delivery metric (available via the Stripe API or Dashboard alerts). Layer in: application logs filtered on StripeSignatureVerificationError and Webhook Error, synthetic check that triggers a stripe trigger payment_intent.succeeded against the staging endpoint daily, and a worker-lag metric on the event-processing queue if you process asynchronously. A drop in the success-rate metric is more reliable than alerting on application 5xx because Stripe’s view excludes events you happily 200’d but failed to actually process.

Recovery sequence. First, stop accepting more damage: confirm the bug is server-side (your logs show the signature mismatch) and not a Stripe outage (check status.stripe.com). Second, identify the affected window from the Stripe delivery log and use stripe events resend evt_xxx or the Dashboard bulk-resend to replay failed events after the fix lands. Third, fix the bug — usually a body-parser regression from a recent middleware change. Do not rotate the signing secret unless you have evidence it leaked; rotating breaks any in-flight retries that Stripe was about to redeliver. Fourth, after the fix is deployed, replay the affected window in event order to keep downstream state consistent.

Postmortem-style preventive. The durable controls: a dedicated webhook secret per endpoint per environment so test/prod cannot cross-pollute; idempotency table keyed on event.id so replays from Stripe (or your own) cannot double-process; a dead-letter queue with replay tooling so events that fail business logic do not get lost; and a smoke test in CI that posts a known-good signed payload to the webhook route and asserts a 2xx. Pair this with a runbook entry that explicitly tells the next on-call which Stripe delivery log to open and how to bulk-resend.

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, Fix: Express CORS Not Working, Fix: Next.js API Route Not Working, and Fix: SSL Certificate Problem.

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