Mantissa Hooksnode is a Mantissa product A Mantissa product mantissatech.xyz

Stripe webhook signature verification failed (and how to fix it)

Author:
hooksnode
Published:
Updated:
  • stripe
  • webhooks
  • troubleshooting

Stripe accepted the payment, then your handler logged Webhook signature verification failed. Err: No signatures found matching the expected signature for payload. Stripe's docs say that error means at least one of three inputs is wrong: the raw body, the Stripe-Signature header, or the endpoint's whsec_ secret. This guide checks each one, then the 5 minute timestamp window, which is a different error.

The Stripe facts below come from Receive Stripe events and Manage webhook endpoints, as published on 2026-11-06.

Check the endpoint secret first

Stripe calls the wrong secret the most common cause. Every endpoint has its own whsec_ secret, and secrets are not interchangeable:

  • Dashboard or Workbench vs stripe listen. Events forwarded by stripe listen are signed with the secret the CLI prints (Ready! Your webhook signing secret is ...). Events sent to an endpoint you created in Workbench or the Dashboard are signed with that endpoint's secret (Reveal secret, or Click to reveal). Do not verify one with the other.
  • Test vs live. Stripe says it generates a unique secret for each endpoint, and that the same URL used with both test and live API keys has a different secret for each mode.
  • More than one endpoint. Each endpoint you verify needs the secret for that endpoint.
  • A rolled secret. In Workbench you can roll a secret and expire the old one immediately, or keep it for up to 24 hours. During that overlap Stripe sends one signature per active secret. If your server only has the old secret after it expired, verification fails. Deploy the new secret before the overlap ends.

Print the secret your process actually loads and compare it with the value for this endpoint and this mode. Keep the whsec_ prefix. Do not trim it, and do not substitute your Stripe secret key or publishable key.

Check the raw body

Stripe signs the exact UTF-8 body it sent. If your framework parses JSON and you serialize it again, key order, spacing, and number formatting change, and the signature no longer matches.

The usual Express mistake is registering express.json() before the webhook route, so the parser consumes the body first. Put the webhook route first, and read the raw bytes inside it:

// Node.js (Express). The webhook route is registered before express.json().
import crypto from "node:crypto";
import express from "express";

const app = express();
const TOLERANCE_SECONDS = 300;

function verifyStripe(rawBody, header, secret) {
  let timestamp = "";
  const signatures = [];
  for (const part of String(header || "").split(",")) {
    const eq = part.indexOf("=");
    if (eq === -1) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === "t") timestamp = value;
    if (key === "v1" && value) signatures.push(value);
  }
  if (!/^[0-9]+$/.test(timestamp) || signatures.length === 0) return false;
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return signatures.some(
    (given) =>
      given.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
  );
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyStripe(req.body, req.get("stripe-signature"), process.env.STRIPE_WEBHOOK_SECRET);
  if (!ok) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  switch (event.type) {
    default:
      console.log(event.id);
      break;
  }
  // Hand off by event.id, then answer. Stripe counts any 2xx as success.
  res.sendStatus(200);
});

app.use(express.json());
# Python (Flask). Get the raw body before any JSON parse.
import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
TOLERANCE_SECONDS = 300

def verify_stripe(raw_body: bytes, header: str, secret: str) -> bool:
    timestamp = ""
    signatures = []
    for part in (header or "").split(","):
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        if key == "t":
            timestamp = value
        elif key == "v1" and value:
            signatures.append(value)
    if not timestamp.isdigit() or not signatures:
        return False
    age = int(time.time()) - int(timestamp)
    if age > TOLERANCE_SECONDS:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(given, expected) for given in signatures)

@app.post("/webhook")
def webhook():
    raw = request.get_data()
    if not verify_stripe(raw, request.headers.get("Stripe-Signature", ""), os.environ["STRIPE_WEBHOOK_SECRET"]):
        return "", 400
    return "", 200

The signed message is the timestamp, a ., then the raw body. The key is the endpoint signing secret, used as that string. Ignore any scheme other than v1 (Stripe also sends a v0 value on test events). Compare in constant time.

Prefer Stripe's official constructEvent (snapshot events) or parseEventNotification (thin events) when you can. Those helpers take the same three inputs. A tolerance of 0 in those libraries turns the recency check off, which Stripe tells you not to do.

Check the Stripe-Signature header

Print the header your code passes in. Stripe's troubleshooting page says it should look like t=xxx,v1=yyy,v0=zzz. If the value is empty, or it is only the v1 hex, the header was dropped or split on the way in.

Common ways it gets mangled:

  • A proxy or framework lowercases or strips headers, and your code reads the wrong name. HTTP header names are case-insensitive. Read Stripe-Signature, not a custom name you invented.
  • The platform parsed the header as a single cookie-style list and kept only one piece.
  • You logged the body and then forwarded a reconstructed request that no longer has the header.

The 5 minute tolerance is a different error

Stripe-Signature includes a timestamp, t, in unix seconds. That timestamp is part of the signed message, so it cannot be edited without breaking the signature. Stripe's libraries default to a tolerance of 5 minutes between that timestamp and your clock. If the signature matches but the timestamp is too old, the libraries reject the event. That is not the "No signatures found..." error.

Sync the server clock with NTP. Stripe signs each delivery attempt itself, including a retry after a non-2xx response, with a new timestamp and a new signature. A retry that comes straight from Stripe can still pass the 5 minute check. A replay of an old captured request cannot.

In live mode Stripe retries for up to three days with exponential backoff. Sandbox deliveries are retried three times over a few hours. When an event you already handled shows up again, skip the work and return a 2xx so Stripe stops. Track the event id (evt_...). Do not use the created timestamp to decide whether you have already processed an event. Stripe says distinct events can share that timestamp.

Debug checklist

  1. Open the endpoint in Workbench, Event deliveries. A row shows Delivered, Pending, or Failed, plus the HTTP status.
  2. Confirm the secret in the running process matches Reveal secret for this endpoint and this mode, or the stripe listen secret if the CLI is the sender.
  3. Log the first and last bytes of the body you verify, and confirm you never ran JSON.parse before the check.
  4. Log Stripe-Signature and confirm it contains t= and at least one v1=.
  5. Confirm the host clock is within a few seconds of UTC.
  6. Return a 2xx before slow work. Stripe treats 3xx redirects as delivery failures, and a timeout as a failure. Point the endpoint at the final URL.

With Hooksnode

Hooksnode can verify Stripe once, store the event, and deliver it to your services on its own schedule.

  1. Create a webhook source and copy its URL (https://hooksnode.com/p/<source-id>).
  2. Turn on Require signature verification.
  3. Under Signature format, choose Stripe.
  4. Paste the endpoint's whsec_ secret into Stripe secret, unchanged, then press Save format. Use the test secret on a test project and the live secret on a live project. Use the stripe listen secret only for CLI-forwarded events.
  5. Set that URL as the Stripe endpoint URL.

The Stripe preset checks Stripe-Signature at ingress. It computes HMAC SHA256 of the unix timestamp, a dot, and the raw body, using the pasted secret string as the key, and it accepts any v1. A timestamp more than 300 seconds before or after the Hooksnode clock is rejected. A missing, malformed, or mismatched signature is stored as rejected, answered with 401, and not delivered. Stripe's own libraries default to rejecting a timestamp older than 5 minutes. The Hooksnode preset is stricter on the future side: it also rejects a timestamp more than 300 seconds ahead.

Hooksnode keeps the raw body and forwards the original headers, including Stripe-Signature. Retries and replays send that same header again. They do not re-sign it. A destination that verifies Stripe-Signature itself can succeed on the first delivery and fail once the timestamp is outside Stripe's 5 minute tolerance, even though the body is unchanged. A transform makes this worse: the forwarded signature was computed over the original body, so it will not match a transformed body.

Destinations should verify X-Hooksnode-Signature instead. On the destination, open Signing and turn on Sign outbound requests. Hooksnode generates a 64-character hex secret. You cannot type your own secret when you create the destination, and the API does not accept a secret value. Turning signing on generates it. Regenerate replaces it, and the previous secret stops matching immediately. Copy Signing secret from the destination page.

With the default signature format (Hooksnode), each attempt sends:

X-Hooksnode-Signature: sha256=<hex>

That hex is the HMAC SHA256 of the body actually delivered (after any transform), keyed with that destination's secret. It has no timestamp, so a later retry still verifies. The destination can instead use the Mantissa format, t=<unix seconds>,v1=<hex>, which is an HMAC SHA256 of the timestamp, a dot, and the delivered body, signed when that attempt is sent. Either way, Hooksnode signs its own header at delivery time. It does not reuse a previous provider signature.

Delivery is at least once, never exactly once. An attempt fails when the destination returns a non-2xx status or does not answer within 15 seconds. Hooksnode makes up to 25 delivery attempts in total. The wait between attempts is capped at 1 hour, so the full run is about 17.5 hours. A HEAD health check is sent to each active destination every 15 minutes. Failure emails go to workspace members who can see that project.

Handlers should be idempotent on the Stripe event id. Hooksnode does not add an Idempotency-Key header of its own. Unsigned sources can dedupe on an inbound X-Idempotency-Key. The Stripe preset is a signed source, so that header is ignored. Hooksnode does drop an exact resend of the same body and signature for 10 minutes. Stripe can still deliver the same event again later, and a replay from the Hooksnode dashboard sends the original Stripe-Signature with it.

A Free project with no destination answers 400 ({"error":"no destinations configured"}). Add a destination before you point Stripe at it. A project on the Pro plan that is not awaiting payment stores an event with no destination as buffered instead of refusing it.

One URL in Stripe, many services behind it, is covered in Send one payment webhook to many services. Local copies of events that arrive while you are connected are covered in Test real webhooks on your laptop. A listen copy keeps the original headers. A dashboard replay does too, and it does not re-sign Stripe-Signature, so verify X-Hooksnode-Signature on the service under test.

How Shuttlers uses Hooksnode is a worked example of one payment webhook reaching several products. The wider routing patterns are in Payment webhook patterns.

Try it free

The project Free plan is $0 and includes 100,000 credits a month, with a hard cap: when credits run out, new events get 402 instead of a bill. Checked against the public plans API on 2026-11-06. See pricing.

FAQ

Why do I see No signatures found matching the expected signature for payload?

Stripe's libraries raise that when the raw body, the Stripe-Signature header, or the endpoint secret does not match. The usual causes are the wrong whsec_ secret, a body that was parsed before the check, or a mangled Stripe-Signature header.

Is the stripe listen secret the same as the Dashboard secret?

No. The secret printed by stripe listen is only for events the CLI forwards. A Dashboard or Workbench endpoint has its own whsec_ secret. Test and live mode also use different secrets for the same URL.

What is Stripe's signature tolerance?

Stripe's libraries default to 5 minutes. A timestamp older than that fails the recency check. Stripe signs each of its own retries with a new timestamp, so a direct retry can still pass.

Why does Stripe-Signature fail on a Hooksnode retry?

Hooksnode forwards the original Stripe-Signature and does not re-sign it on retries or replays. After Stripe's 5 minute tolerance, a destination that checks that header fails. Verify X-Hooksnode-Signature instead.

Does Hooksnode deliver Stripe events exactly once?

No. Delivery is at least once, never exactly once. Make the handler idempotent on the Stripe event id.

Further reading