Kamol Nazarov The back office lab Brooklyn, NY

Lab notes · N°01 · · 3 min read

Fixing Stripe Webhook Signature Verification Failures in Express

express.json() eats the body before constructEvent can verify it: the usual Stripe webhook failure in Express, the raw-body fix, and the rest of the checklist.

Written by
Kamol Nazarov
Published
Tags
stripe · express · webhooks
Follow
RSS feed

The note

The first time I wired Stripe webhooks into one of the shop's tools, every test event came back 400 with this:

StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?

Search "stripe webhook signature verification failed express" and you'll find a hundred possible causes. Nine times out of ten it's exactly one: something parsed req.body as JSON before stripe.webhooks.constructEvent() ever saw the raw bytes � and in an Express app, that something is almost always express.json(). Here's why it happens, the two-line fix, and the much shorter list of suspects when the fix isn't it.

The code that produces the error

// This version fails:
app.post('/webhooks/stripe', express.json(), (req, res) => {
  const sig = req.headers['stripe-signature'];

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

  handleEvent(event);
  res.sendStatus(200);
});

Why the signature check is so picky

When Stripe delivers a webhook, it computes an HMAC-SHA256 of timestamp + '.' + raw_body using the endpoint's signing secret (whsec_...) and ships the result in the stripe-signature header. To verify, the SDK recomputes the same HMAC on your side and compares the two. Identical bytes in, identical hash out � but if the bytes differ by a single character, the signatures don't match, and the SDK refuses to hand you the event.

The catch: an HTTP request body is a stream, and a stream can be read exactly once. express.json() reads it, parses it, and hands your route a convenient object. The raw bytes are gone by then � the SDK can't have them.

The trap that keeps people stuck for an afternoon: JSON.stringify(req.body) sometimes reproduces the original payload byte-for-byte (V8 preserves key insertion order, and Stripe sends compact JSON), so the old Stack Overflow answer of "just re-stringify it" appears to work � right up until a payload contains a number that round-trips differently (1e21 becomes 1e+21), a string that was escaped differently on the wire, or a proxy that normalizes the body in transit. You can't reconstruct the exact bytes from a parsed object. You have to keep them.

The fix: raw body on the webhook route, JSON everywhere else

Express runs middleware and routes in registration order, so give the webhook its own raw-body parser and register it before the global JSON parser:

import express from 'express';

const app = express();

// 1. Webhook route first, with its own raw parser:
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];

    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body, // now a Buffer of the exact bytes Stripe sent
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    handleEvent(event);
    res.sendStatus(200);
  }
);

// 2. Everything else still gets normal JSON parsing:
app.use(express.json());

express.raw({ type: 'application/json' }) only fires for JSON content-types, only on that route, and leaves req.body as the untouched Buffer � exactly what constructEvent wants. If the webhook route lives on a router, the same principle applies: router.post('/stripe', express.raw(...), handler).

If you can't reorder routes: capture the raw body during parsing

Some apps have a global express.json() mounted before routes that are hard to move. The parser lets you hook the buffer before it's parsed:

app.use(express.json({
  verify: (req, res, buf) => {
    // keep a reference to the raw bytes for this route only
    if (req.originalUrl.startsWith('/webhooks/stripe')) {
      req.rawBody = buf;
    }
  }
}));

app.post('/webhooks/stripe', (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  handleEvent(event);
  res.sendStatus(200);
});

Same effect: the SDK gets the original bytes, and the rest of the app never notices.

When the raw-body fix isn't it

The error message tells you which failure you actually have:

constructEvent throws What it means
No signatures found matching the expected signature for payload Bytes or secret are wrong � raw-body problem, or the wrong whsec
Unable to extract timestamp and signatures from header The stripe-signature header never arrived � a proxy stripped or renamed it
Timestamp outside the tolerance zone The event is older than the tolerance window (default 300s) � check the server clock and any long-running queues

Three checks that catch the remaining cases:

  1. Wrong signing secret. Every webhook endpoint has its own secret. Test-mode and live-mode endpoints differ, and if you deleted and re-created the endpoint, the new one got a new whsec_... � your old env var silently stopped being right. Re-copy it from the Dashboard (Developers ? Webhooks ? your endpoint).
  2. A proxy mangling things. nginx, Cloudflare or a WAF in front of the app can rewrite the body or drop unusual headers. Compare what actually arrives with what Stripe sent.
  3. Testing locally with the wrong secret. stripe listen prints its own temporary whsec_... � that's the one your local server needs while testing, not the Dashboard's.

The two-minute loop to confirm the fix

stripe listen --forward-to localhost:3000/webhooks/stripe
# in another terminal:
stripe trigger payment_intent.succeeded

The CLI prints its local secret on startup, forwards the event, and shows the response code your server returned. A 200 means you're done; a 400 plus the table above points at the next suspect.

One sentence to keep: constructEvent hashes the exact bytes Stripe sent � anything that touches the body first changes the math. Give that one route the raw parser and the error stops being mysterious.