Express Rejects Stripe Webhooks with a Signature Mismatch When JSON Middleware Consumes the Raw Body
POST /api/stripe/webhook fails with StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
Why Stripe webhook verification fails
Stripe signs webhook events with an HMAC over the exact request payload bytes. The signature in the Stripe-Signature header is computed from the raw body that Stripe sent, not from a parsed JavaScript object and not from a reserialized JSON string.
That distinction matters because express.json() consumes the request stream and turns it into req.body. After that parsing step, the original byte sequence is gone. If you later call stripe.webhooks.constructEvent(...) with JSON.stringify(req.body) or with any body that was parsed and rebuilt, the bytes no longer match the bytes that Stripe signed.
The verification flow depends on byte-for-byte equality:
- Stripe sends a JSON payload as raw bytes.
- Stripe computes a signature over those raw bytes.
- Your server must compute the same HMAC over the same raw bytes.
- If the payload is parsed first, the byte sequence is no longer available in its original form.
Even when the parsed object looks identical, the reconstructed JSON can differ in whitespace, key order, escaping, or number formatting. HMAC verification is strict. Any change to the byte stream produces a different digest.
The error surface in Express
A typical failing route looks like this:
tsimport express from "express"; import Stripe from "stripe"; const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20", }); app.use(express.json()); app.post("/api/stripe/webhook", (req, res) => { const sig = req.header("stripe-signature"); if (!sig) { return res.status(400).send("Missing Stripe-Signature header"); } try { const event = stripe.webhooks.constructEvent( JSON.stringify(req.body), sig, process.env.STRIPE_WEBHOOK_SECRET! ); res.json({ received: true, type: event.type }); } catch (err) { res.status(400).send((err as Error).message); } }); app.listen(3000);
With express.json() mounted globally, the webhook route receives a parsed object. The call to JSON.stringify(req.body) creates new bytes. Stripe compares the resulting signature against the original header value and rejects it.
A common response is:
No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
That message is specific. It means the secret and header were provided, but the payload bytes used during verification did not match what Stripe signed.
Why express.json() breaks HMAC verification
express.json() is body-parsing middleware. It reads the incoming request stream, buffers it, parses it as JSON, and assigns the result to req.body.
That changes the handling of the stream in two important ways.
First, the request body stream is consumed. A Node.js request is a readable stream. Once middleware reads it, later middleware cannot read the same bytes again unless the body parser saved them somewhere.
Second, JSON parsing is not a lossless transformation. The parser discards formatting details that are part of the original byte sequence. For example:
- spacing around separators may change
- object key order may be preserved by the parser in many cases, but not as a guarantee of the original byte representation
- Unicode escaping can be normalized differently
- numbers can be represented with different textual forms if the object is serialized again
Stripe does not verify an abstract JSON object. It verifies the exact bytes it sent. That is why the webhook route needs access to the raw body, not the parsed object.
Use express.raw() on the webhook route
The fix is to mount raw-body parsing on the webhook route and keep JSON parsing everywhere else.
express.raw({ type: "application/json" }) tells Express to buffer the incoming request body as a Buffer without parsing it. That Buffer contains the exact bytes needed for stripe.webhooks.constructEvent(...).
A correct route setup looks like this:
tsimport express, { Request, Response } from "express"; import Stripe from "stripe"; const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20", }); app.post( "/api/stripe/webhook", express.raw({ type: "application/json" }), (req: Request, res: Response) => { const sig = req.header("stripe-signature"); if (!sig) { return res.status(400).send("Missing Stripe-Signature header"); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( req.body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { return res.status(400).send((err as Error).message); } switch (event.type) { case "checkout.session.completed": break; case "payment_intent.succeeded": break; default: break; } res.json({ received: true }); } ); app.use(express.json()); app.post("/api/other", (req, res) => { res.json({ body: req.body }); }); app.listen(3000);
In this version, the webhook route gets raw bytes. The rest of the app still gets parsed JSON through express.json().
Route ordering matters
Express runs middleware in the order you register it. If app.use(express.json()) comes before the webhook route, the body is parsed before the raw middleware can see it.
That means this is wrong for Stripe webhooks:
tsapp.use(express.json()); app.post("/api/stripe/webhook", express.raw({ type: "application/json" }), handler);
By the time the webhook route runs, the body has already been consumed by express.json(). The raw parser cannot reconstruct the original bytes.
You need the webhook route to be registered before global JSON parsing, or you need to scope JSON parsing away from the webhook path.
A safe pattern is:
tsapp.post( "/api/stripe/webhook", express.raw({ type: "application/json" }), webhookHandler ); app.use(express.json()); app.use(express.urlencoded({ extended: true }));
If your app uses multiple parsers, keep the webhook route above them.
Why Buffer is required
stripe.webhooks.constructEvent accepts the raw payload as a string or Buffer, but for Express webhooks, Buffer is the safer choice.
With express.raw(), req.body is a Buffer. That is exactly what you want because it preserves the original bytes.
tsconst event = stripe.webhooks.constructEvent( req.body, sig, process.env.STRIPE_WEBHOOK_SECRET! );
Using req.body directly avoids any accidental serialization step.
If your route has already parsed the body into an object, there is no reliable way to recover the exact bytes Stripe signed. Re-stringifying the object is not equivalent.
Example with TypeScript types
A minimal TypeScript route can be written like this:
tsimport express from "express"; import Stripe from "stripe"; const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20", }); app.post( "/api/stripe/webhook", express.raw({ type: "application/json" }), (req, res) => { const signature = req.header("stripe-signature"); if (!signature) { return res.status(400).send("Missing stripe-signature header"); } try { const event = stripe.webhooks.constructEvent( req.body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); if (event.type === "invoice.payment_succeeded") { // handle event } res.status(200).send("ok"); } catch (error) { res.status(400).send((error as Error).message); } } ); app.use(express.json());
This keeps the raw payload available for the webhook, then restores normal JSON handling for the rest of the application.
Local verification with the Stripe CLI
The Stripe CLI can forward webhook events to your local server and sign them with a local webhook secret.
Install it and listen for events:
shstripe login stripe listen --forward-to localhost:3000/api/stripe/webhook
The CLI prints a webhook signing secret that starts with whsec_.... Use that value as STRIPE_WEBHOOK_SECRET.
Trigger an event:
shstripe trigger payment_intent.succeeded
If the route is mounted correctly with express.raw({ type: "application/json" }), the event should verify and your handler should return a 200.
If verification fails, check these points:
- the route path matches the forwarded path exactly
- the
Stripe-Signatureheader is present STRIPE_WEBHOOK_SECRETmatches the secret for the endpoint- no JSON parser runs before the raw-body middleware
- the request body is still a
Bufferwhen passed toconstructEvent
Common mistakes
A few patterns repeatedly cause this error.
Parsing globally before the webhook route
This is the most common problem:
tsapp.use(express.json()); app.post("/api/stripe/webhook", handler);
The body is already parsed before the route sees it.
Calling JSON.stringify(req.body)
This changes the payload bytes and breaks the signature check.
Mixing express.json() with express.raw() on the same route
Do not attach both to the Stripe webhook route. The raw parser needs to see the request first and preserve the byte stream.
Using the wrong signing secret
Stripe uses a different whsec_... value per endpoint. A secret from the Dashboard or CLI that belongs to another endpoint will fail verification even if the payload is correct.
Using the wrong content type
express.raw({ type: "application/json" }) only applies when the request has that content type. Stripe sends JSON webhooks with Content-Type: application/json, so this is usually correct. If a proxy rewrites headers, the raw parser may not run.
A complete Express setup
This pattern keeps webhook verification isolated and preserves normal request handling for the rest of the app.
tsimport express from "express"; import Stripe from "stripe"; const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20", }); app.post( "/api/stripe/webhook", express.raw({ type: "application/json" }), (req, res) => { const sig = req.get("stripe-signature"); if (!sig) { return res.status(400).send("Missing stripe-signature header"); } try { const event = stripe.webhooks.constructEvent( req.body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); if (event.type === "checkout.session.completed") { // handle checkout completion } res.status(200).json({ received: true }); } catch (error) { res.status(400).send((error as Error).message); } } ); app.use(express.json()); app.get("/healthz", (_req, res) => { res.send("ok"); }); app.post("/api/profile", (req, res) => { res.json({ received: req.body }); }); app.listen(3000, () => { console.log("listening on port 3000"); });
This configuration avoids the signature mismatch because only the webhook route bypasses JSON parsing.
Practical takeaway
Prefer express.raw({ type: "application/json" }) on the Stripe webhook route, and register that route before any global express.json() middleware. That preserves the exact request bytes required for HMAC verification while keeping JSON parsing enabled for the rest of the app.