The full error, as thrown by stripe-node 22.4.0:
No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? If a webhook request is being forwarded by a third-party tool, ensure that the exact request body, including JSON formatting and new line style, is preserved.
The library computes HMAC-SHA256(secret, `{timestamp}.{raw_body}`) and compares it against each v1 value in the header. A mismatch has exactly three possible causes: the body bytes changed, the secret is wrong, or the header did not survive the trip. A wrong secret and a mutated body throw the identical message, so the answer is never in the error text; it is in what your handler logs.
Find your cause in one deploy
// Log these three things inside the failing handler:
console.log(Buffer.isBuffer(req.body), req.body?.length);
// want: true and a nonzero length (or the untouched text)
console.log(req.headers["stripe-signature"]?.slice(0, 24));
// want: a string starting "t=<unix>,v1=..."
console.log(process.env.STRIPE_WEBHOOK_SECRET?.slice(0, 9));
// want: "whsec_" plus the first characters of the RIGHT secret
| What you see | Cause |
|---|
Body logs as an object, parsed JSON, or false for Buffer.isBuffer | Parsed body |
| Body is raw, and you are testing through the Stripe CLI | CLI secret vs Dashboard secret |
| Body is raw, secret matches the endpoint, header looks off | Mangled header |
| Different message: "No stripe-signature header value was provided." or "Unable to extract timestamp and signatures from header" | Mangled header |
| Different message: "Timestamp outside the tolerance zone" | Replayed or delayed event, or server clock skew; not a signature bug |
The three causes
01
The body was parsed before verification
Every delivery fails, and a body-parsing middleware or framework default is active on the route.
Stripe signs the exact bytes it sent, as the string {timestamp}.{raw_body}. Parsing and re-serializing the body changes those bytes: whitespace, key order, unicode escapes, number formatting. Most frameworks parse JSON by default, so the handler re-stringifies an object and computes an HMAC over bytes Stripe never sent; forwarding tools that re-encode the body break it the same way.
Confirm it
Log what your handler passes to the verifier. In Node, Buffer.isBuffer(body) should be true, or the body should be the untouched text. If you log an object or JSON.stringify output, this is the cause.
Fix
Exempt the webhook route from body parsing and hand the verifier the raw bytes. The framework sections below have the exact change for each stack.
The fix in your framework
Every fix is the same idea: get the untouched bytes to the verifier. Here is the exact change per stack.
Express
// Webhook route first, with the raw body. JSON parsing after.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, // Buffer of raw bytes
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
res.sendStatus(200);
}
);
app.use(express.json()); // must come after the webhook route
The order matters: app.use(express.json()) registered above the webhook route parses the body before your handler runs, and no raw bytes survive.
Next.js App Router
export async function POST(req: Request) {
const body = await req.text(); // never req.json()
const event = stripe.webhooks.constructEvent(
body,
req.headers.get("stripe-signature")!,
process.env.STRIPE_WEBHOOK_SECRET!
);
return new Response(null, { status: 200 });
}
req.text() returns the raw body. req.json() parses it, and re-stringifying the result is the single most common cause of this error in App Router projects.
Next.js Pages Router
import { buffer } from "micro";
export const config = { api: { bodyParser: false } };
export default async function handler(req, res) {
const body = await buffer(req);
const event = stripe.webhooks.constructEvent(
body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
res.status(200).end();
}
Without bodyParser: false, Next.js consumes and parses the stream before your handler sees it.
Django
@csrf_exempt
def stripe_webhook(request):
event = stripe.Webhook.construct_event(
request.body, # raw bytes
request.META["HTTP_STRIPE_SIGNATURE"],
settings.STRIPE_WEBHOOK_SECRET,
)
return HttpResponse(status=200)
request.body is the raw payload. Django REST Framework's request.data is already parsed; verifying against a re-serialization of it fails.
Flask
@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
event = stripe.Webhook.construct_event(
request.get_data(), # raw bytes, not request.get_json()
request.headers["Stripe-Signature"],
WEBHOOK_SECRET,
)
return "", 200
request.get_data() returns the untouched bytes; request.get_json() does not.
Rails
def webhook
event = Stripe::Webhook.construct_event(
request.body.read, # raw body; params is already parsed
request.env["HTTP_STRIPE_SIGNATURE"],
ENV["STRIPE_WEBHOOK_SECRET"]
)
head :ok
end
Skip CSRF verification for this controller action; Stripe cannot send a Rails CSRF token.
AWS Lambda + API Gateway
export const handler = async (event) => {
const body = event.isBase64Encoded
? Buffer.from(event.body, "base64").toString("utf8")
: event.body;
const stripeEvent = stripe.webhooks.constructEvent(
body,
event.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
return { statusCode: 200 };
};
The gateway must pass the body through unmodified. A mapping template that re-serializes JSON changes the bytes and breaks every verification, which is why proxy integrations are the safe default.
Verify the fix end to end
stripe listen --forward-to localhost:3000/webhooks/stripe
# copy the whsec_... this prints; it is NOT your Dashboard secret
stripe trigger payment_intent.succeeded
Trigger an event and watch your handler verify it. The trap to avoid while doing this: stripe listen signs deliveries with its own session secret, printed at startup. Verify CLI-forwarded events with that secret, and switch back to the endpoint's Dashboard secret when real deliveries resume. If you keep both around, name the variables so they cannot be confused.
The same rule on Flint
Flint signs webhooks over the raw body too, in Standard Webhooks format, so every fix on this page carries over unchanged: exempt the route from body parsing, verify the untouched bytes, keep one secret per endpoint. The webhooks guide builds a handler this way from the first line.
Sources