Diagnostic Order status and webhooks
Stripe rejects every webhook because the signing secret does not match
Your endpoint is set up, the events are selected, and Stripe still shows every delivery failing with No signatures found matching the expected signature. Orders never update. The cause is a mismatch between the signing secret saved in the WooCommerce Stripe plugin and the one on the Stripe endpoint. This guide finds that mismatch and tells you exactly what to fix.
Stripe signs every webhook with the endpoint signing secret, and the plugin checks that signature against the secret you saved in its settings. If the two differ, every event is rejected and no order updates. The secret cannot be read back from Stripe, so the script reads the value saved in the plugin with WP-CLI, checks that it looks like a real whsec_ secret, and confirms deliveries are failing. Then you copy the correct secret from the endpoint into the plugin.
The problem in plain words
When Stripe sends a webhook, it adds a signature built from a secret that belongs to the endpoint. The plugin uses its own saved copy of that secret to check the signature is real. This is what stops a stranger from faking payment events. If the saved copy is wrong, the check fails on every single event, and Stripe records each one as rejected with a message about signatures not matching.
The endpoint looks healthy. The events are selected. But nothing gets through, because the plugin throws away every event before it can update an order.
Why it happens
There are a few common ways the saved secret goes wrong. Someone regenerated the signing secret in Stripe but never pasted the new value into the plugin. A test mode secret is saved while the store runs in live mode, or the reverse. The webhook endpoint ID, which starts with we_, was pasted in by mistake instead of the signing secret, which starts with whsec_. A caching layer or CDN changed the raw body of the request, which also breaks the signature. The WooCommerce Stripe plugin has reported cases of all of these. The citations at the end link the threads.
Stripe never lets you read a signing secret back through the API, so no script can compare the two secrets directly. What a script can do is read the value saved in the plugin, check that it even looks like a signing secret, and confirm that deliveries are failing. That is enough to point at the fix, which is to copy the correct secret from the endpoint.
The fix, as a flow
We read the secret and the mode the plugin saved, using WP-CLI. We check the secret looks like a real whsec_ value and not a we_ ID or an empty string. Then we ask Stripe whether recent events are still waiting to be delivered, which is the sign that the endpoint keeps rejecting them. The output tells you to copy the signing secret from the Stripe endpoint into the plugin.
Build it step by step
Judge the saved secret with a pure function
Keep the format check in one function. A good secret starts with whsec_. A value that starts with we_ is the endpoint ID, pasted in by mistake. An empty value means nothing was saved. Each of those is a clear, nameable problem.
def diagnose_secret(secret):
if not secret:
return ["no webhook signing secret is saved in the plugin"]
if secret.startswith("we_"):
return ["the endpoint ID was saved instead of the signing secret (needs the whsec_ value)"]
if not secret.startswith("whsec_"):
return ["the saved value does not look like a signing secret (should start with whsec_)"]
return []
export function diagnoseSecret(secret) {
if (!secret) return ["no webhook signing secret is saved in the plugin"];
if (secret.startsWith("we_")) return ["the endpoint ID was saved instead of the signing secret (needs the whsec_ value)"];
if (!secret.startsWith("whsec_")) return ["the saved value does not look like a signing secret (should start with whsec_)"];
return [];
}
Read the saved secret with WP-CLI
The plugin keeps its settings in the woocommerce_stripe_settings option. WP-CLI reads it as JSON. Run this on the server, or over SSH. If you cannot use WP-CLI, you can pass the secret in through an environment variable instead.
import json, subprocess
def read_plugin_settings():
raw = subprocess.check_output(
["wp", "option", "get", "woocommerce_stripe_settings", "--format=json"],
text=True,
)
data = json.loads(raw)
testmode = data.get("testmode") == "yes"
secret = data.get("test_webhook_secret" if testmode else "webhook_secret", "")
return secret, testmode
import { execFileSync } from "node:child_process";
export function readPluginSettings() {
const raw = execFileSync("wp", ["option", "get", "woocommerce_stripe_settings", "--format=json"], { encoding: "utf8" });
const data = JSON.parse(raw);
const testmode = data.testmode === "yes";
const secret = data[testmode ? "test_webhook_secret" : "webhook_secret"] || "";
return { secret, testmode };
}
Confirm deliveries are failing
A wrong secret shows up as events that keep waiting to be delivered, because the endpoint returns an error on every one. Stripe gives each event a pending_webhooks count. If recent events still have pending deliveries while an endpoint exists, the endpoint is rejecting them, and a secret mismatch is the most likely reason.
import stripe
def deliveries_failing(limit=100):
pending = total = 0
for event in stripe.Event.list(limit=limit).auto_paging_iter():
total += 1
if event.get("pending_webhooks", 0) > 0:
pending += 1
return pending, total
export async function deliveriesFailing(stripe, limit = 100) {
let pending = 0, total = 0;
for await (const event of stripe.events.list({ limit })) {
total++;
if ((event.pending_webhooks || 0) > 0) pending++;
}
return { pending, total };
}
The script never writes to your store or Stripe. It reads the saved secret, checks it, and looks at recent deliveries. The fix itself is a copy and paste. Open the endpoint in the Stripe dashboard, reveal its signing secret, and paste that whsec_ value into the plugin webhook settings for the matching mode.
The full code
The complete diagnostic reads the saved secret with WP-CLI, checks its format, and reports whether deliveries are failing. It tells you the exact remediation in plain words.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find a Stripe webhook signing secret mismatch that makes WooCommerce reject events.
Read only. It reports the problem and the fix, it does not change anything.
Guide: https://www.allanninal.dev/woocommerce/stripe-webhook-signing-secret-mismatch/
"""
import os
import json
import subprocess
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def diagnose_secret(secret):
if not secret:
return ["no webhook signing secret is saved in the plugin"]
if secret.startswith("we_"):
return ["the endpoint ID was saved instead of the signing secret (needs the whsec_ value)"]
if not secret.startswith("whsec_"):
return ["the saved value does not look like a signing secret (should start with whsec_)"]
return []
def read_plugin_settings():
# Falls back to an environment variable when WP-CLI is not available.
if os.environ.get("WC_STRIPE_WEBHOOK_SECRET"):
return os.environ["WC_STRIPE_WEBHOOK_SECRET"], os.environ.get("WC_STRIPE_TESTMODE") == "yes"
raw = subprocess.check_output(
["wp", "option", "get", "woocommerce_stripe_settings", "--format=json"],
text=True,
)
data = json.loads(raw)
testmode = data.get("testmode") == "yes"
secret = data.get("test_webhook_secret" if testmode else "webhook_secret", "")
return secret, testmode
def deliveries_failing(limit=100):
pending = total = 0
for event in stripe.Event.list(limit=limit).auto_paging_iter():
total += 1
if event.get("pending_webhooks", 0) > 0:
pending += 1
return pending, total
def run():
secret, testmode = read_plugin_settings()
mode = "test" if testmode else "live"
print(f"Plugin is in {mode} mode.")
issues = diagnose_secret(secret)
if issues:
for issue in issues:
print(f" PROBLEM: {issue}")
else:
print(" The saved secret looks like a valid whsec_ value.")
pending, total = deliveries_failing()
print(f"Recent events checked: {total}, still pending delivery: {pending}")
if pending and not issues:
print(" The secret format is fine but deliveries still fail. The saved secret is likely")
print(" out of date or from the wrong mode. Copy the signing secret from the Stripe")
print(" endpoint and paste it into the plugin webhook settings for the matching mode.")
if issues:
print(" Fix: open the webhook endpoint in Stripe, reveal its signing secret, and paste the")
print(f" whsec_ value into the plugin webhook settings for {mode} mode.")
if __name__ == "__main__":
run()
/**
* Find a Stripe webhook signing secret mismatch that makes WooCommerce reject events.
* Read only. It reports the problem and the fix, it does not change anything.
*
* Guide: https://www.allanninal.dev/woocommerce/stripe-webhook-signing-secret-mismatch/
*/
import Stripe from "stripe";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
export function diagnoseSecret(secret) {
if (!secret) return ["no webhook signing secret is saved in the plugin"];
if (secret.startsWith("we_")) return ["the endpoint ID was saved instead of the signing secret (needs the whsec_ value)"];
if (!secret.startsWith("whsec_")) return ["the saved value does not look like a signing secret (should start with whsec_)"];
return [];
}
export function readPluginSettings() {
if (process.env.WC_STRIPE_WEBHOOK_SECRET) {
return { secret: process.env.WC_STRIPE_WEBHOOK_SECRET, testmode: process.env.WC_STRIPE_TESTMODE === "yes" };
}
const raw = execFileSync("wp", ["option", "get", "woocommerce_stripe_settings", "--format=json"], { encoding: "utf8" });
const data = JSON.parse(raw);
const testmode = data.testmode === "yes";
const secret = data[testmode ? "test_webhook_secret" : "webhook_secret"] || "";
return { secret, testmode };
}
async function deliveriesFailing(limit = 100) {
let pending = 0, total = 0;
for await (const event of stripe.events.list({ limit })) {
total++;
if ((event.pending_webhooks || 0) > 0) pending++;
}
return { pending, total };
}
export async function run() {
const { secret, testmode } = readPluginSettings();
const mode = testmode ? "test" : "live";
console.log(`Plugin is in ${mode} mode.`);
const issues = diagnoseSecret(secret);
if (issues.length) issues.forEach((i) => console.log(` PROBLEM: ${i}`));
else console.log(" The saved secret looks like a valid whsec_ value.");
const { pending, total } = await deliveriesFailing();
console.log(`Recent events checked: ${total}, still pending delivery: ${pending}`);
if (pending && !issues.length) {
console.log(" The secret format is fine but deliveries still fail. The saved secret is likely");
console.log(" out of date or from the wrong mode. Copy the signing secret from the Stripe");
console.log(" endpoint and paste it into the plugin webhook settings for the matching mode.");
}
if (issues.length) {
console.log(" Fix: open the webhook endpoint in Stripe, reveal its signing secret, and paste the");
console.log(` whsec_ value into the plugin webhook settings for ${mode} mode.`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The format check is the pure piece, and the one worth testing. It takes a string and returns the problems it found.
from check_webhook_secret import diagnose_secret
def test_valid_secret_has_no_issues():
assert diagnose_secret("whsec_abc123") == []
def test_empty_secret_is_flagged():
assert diagnose_secret("") != []
def test_endpoint_id_is_flagged():
assert "endpoint ID" in diagnose_secret("we_123")[0]
def test_random_value_is_flagged():
assert diagnose_secret("hello")[0].startswith("the saved value")
import { test } from "node:test";
import assert from "node:assert/strict";
import { diagnoseSecret } from "./check-webhook-secret.js";
test("valid secret has no issues", () => {
assert.deepEqual(diagnoseSecret("whsec_abc123"), []);
});
test("empty secret is flagged", () => {
assert.ok(diagnoseSecret("").length > 0);
});
test("endpoint id is flagged", () => {
assert.ok(diagnoseSecret("we_123")[0].includes("endpoint ID"));
});
test("random value is flagged", () => {
assert.ok(diagnoseSecret("hello")[0].startsWith("the saved value"));
});
Case studies
The secret that changed without warning
A developer rotated the signing secret in Stripe while cleaning up old endpoints, and never updated the plugin. From that moment every order stopped updating, though checkout still worked and Stripe still charged.
The script confirmed the saved secret looked valid but deliveries were all pending, which pointed straight at a stale secret. Pasting the current one from the endpoint fixed orders on the next payment.
The endpoint ID in the secret box
During setup someone copied the endpoint ID, which starts with we_, into the signing secret field. It looked close enough to be missed by eye.
The format check named it at once: the endpoint ID was saved instead of the signing secret. Swapping in the real whsec_ value cleared every rejection.
After you paste the correct secret, send a test webhook from the Stripe dashboard. It should return a success, and recent events should show zero still pending delivery. New orders will start moving to Processing right away.
FAQ
What does No signatures found matching the expected signature mean?
It means the signing secret stored in the WooCommerce Stripe plugin does not match the secret on the Stripe webhook endpoint. Stripe signs each event with the endpoint secret, and the plugin checks that signature. When the two do not match, every event is rejected and no order updates.
Can I read the Stripe signing secret from the API?
No. The signing secret is shown once in the Stripe dashboard and cannot be read back through the API. A script can flag that the stored value looks wrong and that deliveries are failing, but you copy the correct whsec_ value from the endpoint by hand.
Why did my webhook work and then suddenly stop?
The signing secret was most likely rotated in Stripe without being updated in the plugin, or a test secret is being used with live traffic. Both make every signature check fail.
Related field notes
Citations
On the problem:
- WooCommerce Stripe plugin issue: webhook signature verification failing with a mismatched secret. github.com/woocommerce/woocommerce-gateway-stripe/issues/2398
- Stripe docs: check the webhook signature and the signing secret. docs.stripe.com/webhooks/signature
- WordPress.org forum: No signatures found matching the expected signature. wordpress.org/support/plugin/woocommerce-gateway-stripe
On the solution:
- Stripe API: list events and read pending_webhooks. docs.stripe.com/api/events/list
- WooCommerce docs: the Stripe webhook settings and signing secret. woocommerce.com/document/stripe
- WP-CLI: read an option value. developer.wordpress.org/cli/commands/option/get
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear your rejected events?
If this got your webhook accepted and your orders moving again, you can buy me a coffee. It keeps these field notes free and growing.
Buy me a coffee on Ko-fi