Stripe Webhook Signature Failed — Raw Body Required

Stripe webhooks API

StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Your endpoint logs show a JSON body that looks perfect. Stripe CLI triggers work on a colleague’s laptop. Production fails. Almost every time, the framework parsed JSON before Stripe’s verifier saw the bytes. The signature is HMAC over the raw body string. Re-serialized JSON is a different message.

What Stripe signs

The Stripe-Signature header covers timestamp + '.' + raw_body. If Express ran express.json() globally, req.body is an object. Calling JSON.stringify(req.body) does not restore the original spacing, key order, or Unicode escapes. Verification fails even when the event is genuine.

Express: capture raw bytes for one route

import express from "express";
import Stripe from "stripe";

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

const app = express();

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    if (!sig || Array.isArray(sig)) {
      res.status(400).send("missing signature");
      return;
    }
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
      console.error(err);
      res.status(400).send("invalid signature");
      return;
    }
    // handle event.type ...
    res.json({ received: true });
  },
);

// JSON parser for everything else — AFTER the webhook route
app.use(express.json());

Order matters. A global express.json() registered first will consume the stream; the webhook route never sees raw Buffer/string.

Fastify and Next.js

Fastify: use addContentTypeParser for the webhook path or req.rawBody plugins carefully; do not JSON.parse before constructEvent.

Next.js App Router:

export const runtime = "nodejs";

export async function POST(req: Request) {
  const rawBody = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(
    rawBody,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );
  // ...
  return Response.json({ received: true });
}

Do not req.json() first. In Pages Router, disable bodyParser for that API route and read the buffer from the incoming message.

Other failure modes (after raw body is fixed)

SymptomCause
Works with CLI, fails in DashboardWrong signing secret (whsec_… for that endpoint / mode)
Intermittent failuresLoad balancer or middleware modifying body (gzip re-encode, BOM)
Timestamp outside toleranceServer clock skew — sync NTP; default tolerance is 300s
Test mode events on live secretMixing sk_test webhook secret with live endpoint
Multiple secrets during rotationConstruct with old and new secrets until cutover completes

Idempotency after verify

Signature success only proves authenticity. Stripe retries on 5xx / timeouts. Store event.id and skip duplicates:

if (await db.hasProcessed(event.id)) return res.json({ received: true });
await db.process(event);
await db.markProcessed(event.id);

Return 200 quickly; do heavy work async if needed — but only after durable enqueue, or you will drop work on process crash.

Debug ritual

  1. Log whether typeof body === "string" || Buffer.isBuffer(body) at the verifier.
  2. Confirm the endpoint’s signing secret from Dashboard → Developers → Webhooks → that URL (not the API key).
  3. Replay with Stripe CLI: stripe listen --forward-to localhost:3000/webhooks/stripe.
  4. Compare clock: date -u on the server vs time.is.

Endpoint secrets get mixed up

Each Stripe webhook endpoint has its own whsec_. Verifying production traffic with the CLI secret from stripe listen fails every time. Name secrets by environment and endpoint. During rotation, try constructEvent with the new secret, then fall back to the old for a short window — log which slot succeeded, never the secret value itself.

If you remember one rule: verify the bytes Stripe sent, not the object your framework built. Everything else is secret hygiene and retries.