Stripe Webhooks in Express: The express.json() Trap
One line of body-parser middleware silently breaks every signature check. Here's the correct route ordering.
Zeeshan
Founder · Published
One line of body-parser middleware silently breaks every signature check. Here's the correct route ordering.
Zeeshan
Founder · Published
If you have ever seen "No signatures found matching the expected signature for payload" in an Express app, you have met this bug. It is not a wrong secret, and it is not a Stripe problem. It is express.json() parsing the body into an object before your webhook handler runs.
Stripe computes the signature over the exact bytes it transmitted. express.json() consumes the stream, parses it, and hands you req.body as a JavaScript object. Re-serializing that object gives you semantically identical JSON with different bytes: different key order, different whitespace, and therefore a different signature.
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// The webhook route is mounted BEFORE the global JSON parser, with a raw
// parser scoped to just this path. Order matters. This cannot go after.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // a Buffer here, not an object
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
res.json({ received: true }); // Acknowledge first.
void handleEvent(event); // Then work, off the response path.
},
);
// Every other route gets normal JSON parsing.
app.use(express.json());Express middleware runs in registration order. If app.use(express.json()) appears above the webhook route, it has already consumed the stream by the time the raw parser is reached, and express.raw() will not un-parse it. Mounting the webhook first is not a style preference. It is the fix.
Stripe retries on non-2xx responses and on timeouts, and it can legitimately deliver the same event more than once. Record event.id in a table with a unique constraint and skip anything you have already processed.
Stripe insights, monthly
One email a month, no spam, unsubscribe anytime.
No spam, unsubscribe anytime.
Verification, idempotency, and retries: the three things that separate a toy webhook handler from a production one.
Let Stripe own the billing state and Django own the entitlement. Mixing the two is where it goes wrong.
The raw-body problem bites PHP integrations harder than most. Here's the correct shape.