Reference

No signatures found matching the expected signature for payload

Stripe signs every webhook by computing an HMAC over the exact bytes it sent. This error means the HMAC your code computed does not match any signature in the Stripe-Signature header. Only three things can cause that, and the error text cannot tell you which one you have. The checks below can.

Verified against official documentation · last reviewed 2026-07-31 · corrections: support@withflintpay.com

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

JavaScript
// 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 seeCause
Body logs as an object, parsed JSON, or false for Buffer.isBufferParsed body
Body is raw, and you are testing through the Stripe CLICLI secret vs Dashboard secret
Body is raw, secret matches the endpoint, header looks offMangled 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.

02

The wrong whsec_ secret

The raw body is verifiably untouched and every delivery still fails. Common right after switching between local testing and a deployed endpoint.

Every webhook endpoint has its own signing secret, and the Stripe CLI adds one more: stripe listen prints a session secret that is different from any Dashboard endpoint's, though both start with whsec_. Verifying CLI-forwarded events with the Dashboard secret fails; deploying with the CLI secret baked in fails the same way. Staging, production, and Connect endpoints each have distinct secrets, so one shared environment variable is a standing footgun.

Confirm it

Print the first few characters of the secret your process loaded and compare against the source of the events: the stripe listen output when testing through the CLI, or that exact endpoint's revealed secret in the Dashboard.

Fix

Use the secret belonging to the delivery path in use, one environment variable per endpoint. When rotating, remember in-flight retries were signed with the secret that was active when the event was created.

03

The Stripe-Signature header did not survive the trip

A different pair of errors: a missing-header message, or one about extracting timestamps and signatures from the header.

The header must arrive as t=<unix>,v1=<hex> and reach the verifier verbatim. Proxies that strip unrecognized headers, frameworks that fold repeated headers into arrays, and handlers that read the wrong header name all hand the verifier something it cannot parse. These failures produce their own messages, which is useful: they clear the body and the secret of suspicion.

Confirm it

Log the raw header value in the handler. It should start with t= and contain at least one v1= segment. undefined means it never arrived; an array means your framework folded it.

Fix

Read the header exactly (stripe-signature, case-insensitive), pass it through untouched, and configure proxies to forward it. If it arrives as an array, pass the first element.

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

JavaScript
// 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

TypeScript
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

TypeScript
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

Python
@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

Python
@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

Ruby
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

JavaScript
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

Bash
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