Webhook signature verification failed
What this symptom means
The provider delivered an HTTP request. Your code then computed a signature (or compared a secret token) and decided the request was not authentic. Typical log lines are SignatureVerificationError, No signatures found matching the expected signature for payload, or a 400 you returned after a failed check.
That is not the same as “the webhook never arrived.” If Mercur’s request history has a row, the bytes and headers are there to compare with what you hashed.
Mercur does not verify Stripe, GitHub, or Telegram signatures. Treat the public URL as untrusted input and verify in your handler, the same way you would in production.
Common causes
- Parsing JSON (or running
express.json()/request.json()) before the verifier, so the HMAC runs over a reserialized body - Using a different secret than the one tied to this endpoint (Stripe endpoint secret vs account secret; GitHub repo secret vs org secret)
- Hashing a UTF-16 string, a pretty-printed body, or a truncated capture instead of the raw POST bytes
- Comparing HMAC hex with
===after a length mismatch, or usingX-Hub-Signature(SHA-1) when the provider sentX-Hub-Signature-256 - Telegram: treating
secret_tokenlike an HMAC of the body — it is a header equality check, not a body signature - Forwarding through an endpoint that rewrites the body; the handler then verifies a modified payload. History keeps the original snapshot when capture is on
Verify the incoming request
Open the delivery in Mercur request history and write down:
- Method and path
- The signature header name and full value (
Stripe-Signature,X-Hub-Signature-256, orX-Telegram-Bot-Api-Secret-Token) - Body encoding (JSON text vs
base64:prefix when the capture stored binary) - Whether the body was omitted because it exceeded 256 KB — omitted bodies cannot be hashed or replayed
Point the provider at the webhook inspector first if you are not sure what left their network. Guest mode records the request as received (no body modifiers) and answers 204.
Provider and framework notes
Stripe. Header Stripe-Signature looks like t=<unix>,v1=<hex>. The signed payload is `${t}.${rawBody}`, HMAC-SHA256, using the endpoint signing secret from Developers → Webhooks. constructEvent needs those raw bytes. The Stripe SDK also rejects timestamps that are too old — a captured event can fail verification minutes later even when the HMAC of the stored body is correct.
In Express, use express.raw({ type: "application/json" }) on the webhook route, not express.json(). In the Next.js App Router, call request.text() (or request.arrayBuffer()) and pass that string into constructEvent; request.json() consumes the body first. Full handlers: Next.js webhook localhost, Express webhook localhost, FastAPI webhook localhost, Django webhook localhost.
GitHub. Header X-Hub-Signature-256 is sha256= plus hex HMAC-SHA256 of the raw body with the webhook secret. If you never set a secret, the header is absent. GitHub still documents the SHA-1 X-Hub-Signature header for legacy webhooks — do not mix algorithms. See Validating webhook deliveries.
import { createHmac, timingSafeEqual } from "node:crypto";
function githubSignature256(secret, rawBody) {
const digest = createHmac("sha256", secret).update(rawBody).digest("hex");
return `sha256=${digest}`;
}
const expected = githubSignature256(process.env.GITHUB_WEBHOOK_SECRET, rawBody);
const actual = request.headers["x-hub-signature-256"] ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(actual);
const ok = a.length === b.length && timingSafeEqual(a, b);Telegram. setWebhook secret_token is echoed as X-Telegram-Bot-Api-Secret-Token. Compare that header to your stored token. There is no HMAC over the JSON body.
Do not generalize “always use raw body HMAC” to every provider.
Debugging checklist
- Confirm the request exists in Mercur (or the inspector) with the signature header present.
- Copy the raw body from history; do not pretty-print it.
- Confirm the secret in env matches the secret for this provider endpoint.
- Hash the same bytes the provider hashed (Stripe:
t.payload; GitHub: body only). - Compare with a timing-safe equality check.
- If you already parsed JSON, change the route to keep the raw buffer and parse after verification.
Confirm with the webhook inspector
Paste the inspector URL into the provider and trigger one event. Guest mode needs no account, answers 204 No Content, does not forward to localhost, and expires after 1 idle day. Capture is capped at 256 KB per side.
If the inspector shows Stripe-Signature / X-Hub-Signature-256 and a body, the provider signed a real delivery. Failures after that are in your verifier, not in “the webhook never sent.”
Inspect the request Connect localhost
Connect localhost
When the handler must run for real (it is the only place verification happens):
- Create a Mercur HTTP Endpoint aimed at your local port.
- Connect the macOS app or the npm CLI and start Log and Proxy or Proxy.
- Point the provider at that Endpoint URL.
- Keep the signing secret in local env — not in Mercur.
Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.
Walkthrough: Expose a local service · CLI agent.
Replay the same event
After you stop parsing the body before HMAC, open console request history and use Retry. Mercur re-sends the stored method, path, headers, and body through the public URL.
Retry needs a stored HTTP log, proxy mode (not guest / log-only), a connected agent, and a complete payload. Guest inspector cannot replay.
Stripe timestamp checks can still fail on an old captured event even when the HMAC is right. GitHub body HMAC does not include a timestamp in the signature string, so Retry is more likely to pass the crypto check after a handler fix.
Related
- Webhook 400
- Webhook not received
- Duplicate webhook
- Next.js webhook localhost
- Express webhook localhost
- FastAPI webhook localhost
- Django webhook localhost
- Test Stripe webhooks locally
- Test GitHub webhooks locally
- How to test webhooks
- Receive a webhook
- Expose a local service
- Security and data retention
- Replay a stored request
- Online webhook inspector