Diagnostic Account and store migration

Detect test vs live Stripe key mixups on a WooCommerce store

Checkout looks fine. The form submits. Then every single charge comes back declined, and the Stripe dashboard shows nothing at all, because you are looking at the wrong dashboard. A test key sitting where a live key should be, or the reverse on a staging site, will reject every real card while looking like a normal integration problem. Here is why the mixup happens and a small script that finds it with certainty, using the store's own order data.

Python and Node.js Read only by default Confirms with a real Stripe call
A green circuit board
Photo by Tyler Daviaux on Unsplash
The short answer

Every Stripe secret key carries its own mode in its prefix, sk_test_ or sk_live_. Compare that prefix to the WooCommerce Stripe gateway's own testmode setting. If they disagree, you have a config drift. Then confirm it for certain by asking Stripe about a real PaymentIntent from a recent order, using its id from order meta _stripe_intent_id or transaction_id. If Stripe replies that "a similar object exists in live mode, but a test mode key was used," the mixup is proven, not guessed. Full code, tests, and a dry run guard are below.

The problem in plain words

Stripe runs two completely separate worlds side by side: test mode and live mode. They share nothing. A card, a customer, a PaymentIntent made in one mode simply does not exist as far as the other mode is concerned. The only thing that decides which world your code is talking to is the secret key you send with the request.

WooCommerce stores this key in the Stripe gateway settings, alongside a separate switch called Test mode. Normally the switch and the key agree. But a launch day copy and paste, a staging site cloned from production, or a key rotated in one place and forgotten in another, can leave the switch and the key pointing at different worlds. The checkout form still renders. The card still gets typed in. Then Stripe rejects the request outright, and the store owner sees only "your card was declined," with no clue that the actual cause is a key, not a card.

Buyer pays with a real card Store sends charge key: sk_test_... store thinks it is live wrong world Stripe live mode has no such object Charge fails every order
The card is real and the request is well formed. It fails anyway because the key sends it to the wrong Stripe world.

Why it happens

Stripe's own API documentation is explicit that test and live mode data are fully isolated from each other and that the key alone decides which one you can reach. A few common ways a WooCommerce store ends up with the wrong key in the wrong place:

The WooCommerce Stripe gateway documentation walks through where the test and live keys are entered separately for exactly this reason, and mixing them up is one of the most common setup mistakes reported in Stripe integration support threads. See the citations at the end for the exact references.

The key insight

You do not need to guess whether a key mixup is happening. Stripe already knows, and it tells you in plain language. Ask it about an object id that should exist, such as a recent order's PaymentIntent, and Stripe will either find it or reply that "a similar object exists in live mode, but a test mode key was used" (or the reverse). That single sentence is the whole detection story.

The fix, as a flow

We never touch the checkout and we never write a key anywhere. The script does two checks. First a cheap, no network check, comparing the mode written on the configured secret key against the WooCommerce Stripe gateway's own testmode setting. Second, a real confirmation, asking Stripe directly about one recent order's PaymentIntent. If Stripe's answer names the mismatch, we log it and add an order note, so the shop owner sees an explanation instead of a vague decline.

Read key mode sk_test_ or sk_live_ Read gateway setting Stripe testmode: yes/no Modes agree? no: config drift yes, keep checking Probe one order's PaymentIntent on Stripe Match: nothing to do Confirmed: note the order
A cheap config check first, then a real Stripe call to confirm. Only a confirmed mismatch gets reported.

Build it step by step

1

Get access to both systems

You need the Stripe secret key the store is currently configured to use, and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read access to orders and to the payment gateways endpoint. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_ORDERS="20"
export DRY_RUN="true"   # start safe, change to false to write a note
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_ORDERS="20"
export DRY_RUN="true"   // start safe, change to false to write a note
2

Read the key's own mode from its prefix

Every Stripe secret key and restricted key says its mode right in the string. sk_test_ and rk_test_ mean test mode. sk_live_ and rk_live_ mean live mode. This check costs nothing, no network call needed, and catches most mixups on its own.

step2.py
def key_mode(secret_key):
    """"test", "live", or "unknown" from a Stripe secret or restricted key prefix."""
    if not secret_key:
        return "unknown"
    if secret_key.startswith("sk_test_") or secret_key.startswith("rk_test_"):
        return "test"
    if secret_key.startswith("sk_live_") or secret_key.startswith("rk_live_"):
        return "live"
    return "unknown"
step2.js
export function keyMode(secretKey) {
  if (!secretKey) return "unknown";
  if (secretKey.startsWith("sk_test_") || secretKey.startsWith("rk_test_")) return "test";
  if (secretKey.startsWith("sk_live_") || secretKey.startsWith("rk_live_")) return "live";
  return "unknown";
}
3

Read the WooCommerce Stripe gateway's declared mode

The WooCommerce REST API exposes each payment gateway's settings, including Stripe's own testmode flag, at /wp-json/wc/v3/payment_gateways/stripe. This is the mode the store believes it is running in, independent of whatever key actually sits behind it.

step3.py
import requests
from requests.auth import HTTPBasicAuth

WOO_URL = "https://yourstore.com"
AUTH = HTTPBasicAuth("ck_...", "cs_...")


def gateway_test_mode(settings):
    """True if the WooCommerce Stripe gateway is set to test mode, from its settings dict."""
    value = (settings or {}).get("testmode", {}).get("value")
    return str(value).lower() == "yes"


def get_gateway_settings():
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/payment_gateways/stripe", auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json().get("settings", {})
step3.js
const WOO_URL = "https://yourstore.com";
const AUTH = "Basic " + Buffer.from("ck_...:cs_...").toString("base64");

export function gatewayTestMode(settings) {
  const value = settings && settings.testmode && settings.testmode.value;
  return String(value).toLowerCase() === "yes";
}

async function getGatewaySettings() {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3/payment_gateways/stripe`, {
    headers: { Authorization: AUTH },
  });
  if (!res.ok) throw new Error(`Woo gateway lookup returned ${res.status}`);
  const gateway = await res.json();
  return gateway.settings || {};
}
4

Confirm it for certain with one real Stripe call

Config settings can themselves be stale or wrong, so the strongest proof comes from Stripe directly. Take the PaymentIntent id saved on a recent order, in meta _stripe_intent_id or transaction_id, and try to read it with the configured key. If the key is in the wrong mode, Stripe replies with a very specific sentence naming the actual mode. We parse that sentence instead of guessing from a status code.

step4.py
import re
import stripe

MODE_MISMATCH_RE = re.compile(r"similar object exists in (live|test) mode", re.IGNORECASE)


def mode_mismatch_from_error(message):
    """Parse a Stripe InvalidRequestError message for the 'wrong mode key' signature.
    Returns the mode the object actually lives in ("live" or "test"), or None."""
    if not message:
        return None
    match = MODE_MISMATCH_RE.search(message)
    return match.group(1).lower() if match else None


def probe_intent(intent_id):
    if not intent_id:
        return None
    try:
        stripe.PaymentIntent.retrieve(intent_id)
        return None
    except stripe.error.InvalidRequestError as exc:
        return str(exc)
step4.js
const MODE_MISMATCH_RE = /similar object exists in (live|test) mode/i;

export function modeMismatchFromError(message) {
  if (!message) return null;
  const match = MODE_MISMATCH_RE.exec(message);
  return match ? match[1].toLowerCase() : null;
}

async function probeIntent(stripe, intentId) {
  if (!intentId) return null;
  try {
    await stripe.paymentIntents.retrieve(intentId);
    return null;
  } catch (err) {
    if (err && err.type === "StripeInvalidRequestError") return err.message;
    throw err;
  }
}
5

Decide, with one pure function

Keep the decision in its own function that takes the configured key's mode, the gateway's declared mode, and an optional probe error, and returns a verdict. A pure function like this is easy to read and easy to test, which we do later. A confirmed Stripe error always wins over the settings, because it is stronger evidence.

decide.py
def decide(configured_key_mode, store_test_mode, probe_error_message=None):
    """Pure decision function. No I/O."""
    expected_mode = "test" if store_test_mode else "live"

    if configured_key_mode == "unknown":
        return ("inconclusive", "could not read the configured key's mode")

    probed_mode = mode_mismatch_from_error(probe_error_message)
    if probed_mode is not None:
        return (
            "confirmed_mismatch",
            f"Stripe confirms the order's data lives in {probed_mode} mode, "
            f"but the configured key is a {configured_key_mode} mode key",
        )

    if configured_key_mode != expected_mode:
        return (
            "config_drift",
            f"WooCommerce is set to {expected_mode} mode but the configured "
            f"Stripe key is a {configured_key_mode} mode key",
        )

    return ("match", "configured key mode matches the store's declared mode")
decide.js
export function decide(configuredKeyMode, storeTestMode, probeErrorMessage = null) {
  const expectedMode = storeTestMode ? "test" : "live";

  if (configuredKeyMode === "unknown") {
    return ["inconclusive", "could not read the configured key's mode"];
  }

  const probedMode = modeMismatchFromError(probeErrorMessage);
  if (probedMode !== null) {
    return [
      "confirmed_mismatch",
      `Stripe confirms the order's data lives in ${probedMode} mode, ` +
        `but the configured key is a ${configuredKeyMode} mode key`,
    ];
  }

  if (configuredKeyMode !== expectedMode) {
    return [
      "config_drift",
      `WooCommerce is set to ${expectedMode} mode but the configured ` +
        `Stripe key is a ${configuredKeyMode} mode key`,
    ];
  }

  return ["match", "configured key mode matches the store's declared mode"];
}
6

Report it and stop, with a dry run guard

Run the config check first, then loop through a small number of recent orders looking for one with a saved PaymentIntent id to probe. As soon as one confirmed mismatch is found, that is enough evidence for the whole store's key, so we log it, optionally add an order note, and stop. Leave DRY_RUN on until you trust the output, then turn it off so the note gets written for the team to see.

Run it safe

This script never rotates, writes, or displays a key, and it never changes an order's status. With DRY_RUN=true, the only output is log lines. Turn it off only to have it add a single explanatory order note when it finds a confirmed mismatch.

The full code

Here is the complete detector in one file for each language. It reads settings from the environment, checks the gateway config, confirms with one real Stripe call against a recent order, and respects the dry run flag throughout.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

detect_key_mixup.py
"""Detect a Stripe test/live key mixup on a WooCommerce store.

A store can end up calling Stripe with a secret key from the wrong mode: a
test key left behind after a launch, a live key pasted into a staging site,
or a key rotated in one place but not the other. When that happens, every
charge that touches an object created in the other mode fails, and Stripe's
own error message says exactly why: "a similar object exists in live mode
[or test mode], but a test mode key [or live mode key] was used to make this
request." This script reads the WooCommerce Stripe gateway settings, checks
whether the secret key we were given matches the store's configured mode,
and confirms the mismatch (or clears it) by asking Stripe about a recent
order's PaymentIntent. It never changes a key. It only reports what it finds
as an order note and a log line. Read only by default. Run on demand or on
a schedule.
"""
import os
import re
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_key_mixup")

STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"]
stripe.api_key = STRIPE_SECRET_KEY
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_ORDERS = int(os.environ.get("LOOKBACK_ORDERS", "20"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

MODE_MISMATCH_RE = re.compile(
    r"similar object exists in (live|test) mode", re.IGNORECASE
)


def key_mode(secret_key):
    """"test", "live", or "unknown" from a Stripe secret or restricted key prefix."""
    if not secret_key:
        return "unknown"
    if secret_key.startswith("sk_test_") or secret_key.startswith("rk_test_"):
        return "test"
    if secret_key.startswith("sk_live_") or secret_key.startswith("rk_live_"):
        return "live"
    return "unknown"


def gateway_test_mode(settings):
    """True if the WooCommerce Stripe gateway is set to test mode, from its settings dict."""
    value = (settings or {}).get("testmode", {}).get("value")
    return str(value).lower() == "yes"


def mode_mismatch_from_error(message):
    """Parse a Stripe InvalidRequestError message for the 'wrong mode key' signature.

    Returns the mode the object actually lives in ("live" or "test"), or None
    if the message is not that specific error.
    """
    if not message:
        return None
    match = MODE_MISMATCH_RE.search(message)
    return match.group(1).lower() if match else None


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def decide(configured_key_mode, store_test_mode, probe_error_message=None):
    """Pure decision function. No I/O.

    configured_key_mode: "test" | "live" | "unknown", from key_mode() on our own key.
    store_test_mode: bool, from the WooCommerce Stripe gateway's testmode setting.
    probe_error_message: the Stripe error message from a live API call, if one was made,
                          else None when no probe was run or the probe succeeded.

    Returns (verdict, reason):
      "match"             configuration and probe agree, nothing to do
      "config_drift"      the gateway's declared mode disagrees with our key, before
                           even calling Stripe. Worth fixing even if no probe ran yet.
      "confirmed_mismatch" a live Stripe call proved objects belong to the other mode
      "inconclusive"       we do not have enough signal to say either way
    """
    expected_mode = "test" if store_test_mode else "live"

    if configured_key_mode == "unknown":
        return ("inconclusive", "could not read the configured key's mode")

    probed_mode = mode_mismatch_from_error(probe_error_message)
    if probed_mode is not None:
        return (
            "confirmed_mismatch",
            f"Stripe confirms the order's data lives in {probed_mode} mode, "
            f"but the configured key is a {configured_key_mode} mode key",
        )

    if configured_key_mode != expected_mode:
        return (
            "config_drift",
            f"WooCommerce is set to {expected_mode} mode but the configured "
            f"Stripe key is a {configured_key_mode} mode key",
        )

    return ("match", "configured key mode matches the store's declared mode")


def get_gateway_settings():
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/payment_gateways/stripe", auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json().get("settings", {})


def recent_orders(limit):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"per_page": limit, "orderby": "date", "order": "desc"},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()


def probe_intent(intent_id):
    """Try to read one PaymentIntent with the configured key. Returns the error
    message string on an InvalidRequestError, or None if it succeeded or there
    was nothing to probe."""
    if not intent_id:
        return None
    try:
        stripe.PaymentIntent.retrieve(intent_id)
        return None
    except stripe.error.InvalidRequestError as exc:
        return str(exc)


def note_order(order_id, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={
            "note": f"Key mismatch check: {reason}. Verify the Stripe secret key "
                    f"configured for this store matches the mode (test or live) "
                    f"you intend to run in."
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    settings = get_gateway_settings()
    store_test_mode = gateway_test_mode(settings)
    configured_mode = key_mode(STRIPE_SECRET_KEY)

    # Config-only check first. This alone catches most mixups with zero API risk.
    verdict, reason = decide(configured_mode, store_test_mode)
    if verdict == "config_drift":
        log.warning("Config drift found before any Stripe call: %s", reason)
    else:
        log.info("Config check: %s", reason)

    # Confirm (or clear) with a live probe against a recent real order, since the
    # gateway setting can itself be wrong or stale.
    checked = 0
    confirmed = 0
    for order in recent_orders(LOOKBACK_ORDERS):
        intent_id = intent_id_of(order)
        if not intent_id:
            continue
        checked += 1
        error_message = probe_intent(intent_id)
        probe_verdict, probe_reason = decide(configured_mode, store_test_mode, error_message)
        if probe_verdict == "confirmed_mismatch":
            confirmed += 1
            log.warning("Order %s: %s. %s", order["id"], probe_reason,
                        "would note" if DRY_RUN else "noting")
            if not DRY_RUN:
                note_order(order["id"], probe_reason)
            # One confirmed mismatch is enough evidence for the whole store's key.
            break

    log.info(
        "Done. checked %d order(s), %s.",
        checked,
        "confirmed a key mode mismatch" if confirmed else "no confirmed mismatch",
    )


if __name__ == "__main__":
    run()
detect-key-mixup.js
/**
 * Detect a Stripe test/live key mixup on a WooCommerce store.
 *
 * A store can end up calling Stripe with a secret key from the wrong mode: a
 * test key left behind after a launch, a live key pasted into a staging site,
 * or a key rotated in one place but not the other. When that happens, every
 * charge that touches an object created in the other mode fails, and Stripe's
 * own error message says exactly why: "a similar object exists in live mode
 * [or test mode], but a test mode key [or live mode key] was used to make
 * this request." This script reads the WooCommerce Stripe gateway settings,
 * checks whether the secret key we were given matches the store's configured
 * mode, and confirms the mismatch (or clears it) by asking Stripe about a
 * recent order's PaymentIntent. It never changes a key. It only reports what
 * it finds as an order note and a log line. Read only by default. Run on
 * demand or on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/detect-test-vs-live-key-mixups/
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "sk_test_dummy";
const stripe = new Stripe(STRIPE_SECRET_KEY);
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_ORDERS = Number(process.env.LOOKBACK_ORDERS || 20);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const MODE_MISMATCH_RE = /similar object exists in (live|test) mode/i;

export function keyMode(secretKey) {
  if (!secretKey) return "unknown";
  if (secretKey.startsWith("sk_test_") || secretKey.startsWith("rk_test_")) return "test";
  if (secretKey.startsWith("sk_live_") || secretKey.startsWith("rk_live_")) return "live";
  return "unknown";
}

export function gatewayTestMode(settings) {
  const value = settings && settings.testmode && settings.testmode.value;
  return String(value).toLowerCase() === "yes";
}

export function modeMismatchFromError(message) {
  if (!message) return null;
  const match = MODE_MISMATCH_RE.exec(message);
  return match ? match[1].toLowerCase() : null;
}

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

/**
 * Pure decision function. No I/O.
 *
 * configuredKeyMode: "test" | "live" | "unknown", from keyMode() on our own key.
 * storeTestMode: boolean, from the WooCommerce Stripe gateway's testmode setting.
 * probeErrorMessage: the Stripe error message from a live API call, if one was made,
 *                     else null/undefined when no probe was run or the probe succeeded.
 *
 * Returns [verdict, reason]:
 *   "match"              configuration and probe agree, nothing to do
 *   "config_drift"       the gateway's declared mode disagrees with our key, before
 *                         even calling Stripe. Worth fixing even if no probe ran yet.
 *   "confirmed_mismatch" a live Stripe call proved objects belong to the other mode
 *   "inconclusive"       we do not have enough signal to say either way
 */
export function decide(configuredKeyMode, storeTestMode, probeErrorMessage = null) {
  const expectedMode = storeTestMode ? "test" : "live";

  if (configuredKeyMode === "unknown") {
    return ["inconclusive", "could not read the configured key's mode"];
  }

  const probedMode = modeMismatchFromError(probeErrorMessage);
  if (probedMode !== null) {
    return [
      "confirmed_mismatch",
      `Stripe confirms the order's data lives in ${probedMode} mode, ` +
        `but the configured key is a ${configuredKeyMode} mode key`,
    ];
  }

  if (configuredKeyMode !== expectedMode) {
    return [
      "config_drift",
      `WooCommerce is set to ${expectedMode} mode but the configured ` +
        `Stripe key is a ${configuredKeyMode} mode key`,
    ];
  }

  return ["match", "configured key mode matches the store's declared mode"];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function getGatewaySettings() {
  const gateway = await woo("/payment_gateways/stripe");
  return gateway.settings || {};
}

async function recentOrders(limit) {
  return woo(`/orders?per_page=${limit}&orderby=date&order=desc`);
}

async function probeIntent(intentId) {
  if (!intentId) return null;
  try {
    await stripe.paymentIntents.retrieve(intentId);
    return null;
  } catch (err) {
    if (err && err.type === "StripeInvalidRequestError") return err.message;
    throw err;
  }
}

async function noteOrder(orderId, reason) {
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Key mismatch check: ${reason}. Verify the Stripe secret key ` +
            `configured for this store matches the mode (test or live) you intend to run in.`,
    }),
  });
}

export async function run() {
  const settings = await getGatewaySettings();
  const storeTestMode = gatewayTestMode(settings);
  const configuredMode = keyMode(STRIPE_SECRET_KEY);

  const [configVerdict, configReason] = decide(configuredMode, storeTestMode);
  if (configVerdict === "config_drift") {
    console.warn(`Config drift found before any Stripe call: ${configReason}`);
  } else {
    console.log(`Config check: ${configReason}`);
  }

  const orders = await recentOrders(LOOKBACK_ORDERS);
  let checked = 0;
  let confirmed = 0;
  for (const order of orders) {
    const intentId = intentIdOf(order);
    if (!intentId) continue;
    checked++;
    const errorMessage = await probeIntent(intentId);
    const [probeVerdict, probeReason] = decide(configuredMode, storeTestMode, errorMessage);
    if (probeVerdict === "confirmed_mismatch") {
      confirmed++;
      console.warn(`Order ${order.id}: ${probeReason}. ${DRY_RUN ? "would note" : "noting"}`);
      if (!DRY_RUN) await noteOrder(order.id, probeReason);
      break;
    }
  }

  console.log(
    `Done. checked ${checked} order(s), ${confirmed ? "confirmed a key mode mismatch" : "no confirmed mismatch"}.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a real store gets told its payments are broken. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain strings, booleans, and error messages, and checks the verdict.

test_keymixup_decide.py
from detect_key_mixup import decide, key_mode, mode_mismatch_from_error

LIVE_MODE_ERROR = (
    "No such payment_intent: 'pi_123'; a similar object exists in live mode, "
    "but a test mode key was used to make this request."
)


def test_key_mode_detects_test_secret_key():
    assert key_mode("sk_test_abc123") == "test"


def test_mode_mismatch_from_error_live():
    assert mode_mismatch_from_error(LIVE_MODE_ERROR) == "live"


def test_decide_match_when_key_and_store_agree():
    verdict, _ = decide("live", store_test_mode=False)
    assert verdict == "match"


def test_decide_config_drift_when_test_key_but_store_says_live():
    verdict, reason = decide("test", store_test_mode=False)
    assert verdict == "config_drift"
    assert "live mode" in reason


def test_decide_confirmed_mismatch_overrides_matching_config():
    verdict, reason = decide("live", store_test_mode=False, probe_error_message=LIVE_MODE_ERROR)
    assert verdict == "confirmed_mismatch"
    assert "live mode" in reason


def test_decide_inconclusive_when_key_mode_unknown():
    verdict, _ = decide("unknown", store_test_mode=False)
    assert verdict == "inconclusive"
detect-key-mixup.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, keyMode, modeMismatchFromError } from "./detect-key-mixup.js";

const LIVE_MODE_ERROR =
  "No such payment_intent: 'pi_123'; a similar object exists in live mode, " +
  "but a test mode key was used to make this request.";

test("keyMode detects test secret key", () => {
  assert.equal(keyMode("sk_test_abc123"), "test");
});

test("modeMismatchFromError live", () => {
  assert.equal(modeMismatchFromError(LIVE_MODE_ERROR), "live");
});

test("decide match when key and store agree", () => {
  assert.equal(decide("live", false)[0], "match");
});

test("decide config_drift when test key but store says live", () => {
  const [verdict, reason] = decide("test", false);
  assert.equal(verdict, "config_drift");
  assert.match(reason, /live mode/);
});

test("decide confirmed_mismatch overrides matching config", () => {
  const [verdict, reason] = decide("live", false, LIVE_MODE_ERROR);
  assert.equal(verdict, "confirmed_mismatch");
  assert.match(reason, /live mode/);
});

test("decide inconclusive when key mode unknown", () => {
  assert.equal(decide("unknown", false)[0], "inconclusive");
});

Case studies

Launch day

The store that launched on a test key

A shop flipped its WooCommerce Stripe gateway to live mode for launch day, but the secret key field still held the test key from development, since the switch and the key are set separately in the settings screen. Every real card was declined for the first four hours of a paid ad campaign.

The config-only check alone would have caught this in seconds: the gateway said live, the key said test. Running it as part of the launch checklist would have stopped the campaign spend from going to waste.

Staging clone

The staging site that quietly used the live key

A staging environment was cloned from production for a plugin test, and the clone kept the live Stripe key. A tester ran through checkout with a real card to confirm the plugin worked, and it charged the card for real, on a site nobody expected to touch money.

The probe step would have confirmed this immediately: asking Stripe about the staging order's PaymentIntent would have succeeded outright, since the key genuinely was live, catching the drift before more test charges went out.

What good looks like

Run this check once after any key rotation, environment clone, or gateway settings change, and again on a light schedule as a safety net. A confirmed mismatch becomes a clear order note and a log line within minutes, instead of a pile of "my card was declined" support tickets and a launch day nobody wants to repeat.

FAQ

How do I know if my WooCommerce store is using the wrong Stripe key?

Check the WooCommerce Stripe gateway's declared mode against the prefix of the secret key it holds, sk_test_ for test mode or sk_live_ for live mode. If they disagree, that is a config drift. To be fully sure, ask Stripe about a real PaymentIntent from a recent order. If Stripe replies that a similar object exists in the other mode, the mismatch is confirmed.

What does the Stripe error about a similar object in live or test mode mean?

It means the object you asked for, such as a PaymentIntent, was created under one mode (test or live) but you queried it with a secret key from the other mode. Stripe keeps test and live data fully separate, so the key you use decides which side it can see.

Is it safe to run a key mixup check on a live store?

Yes. The check only reads the gateway settings and a small sample of recent PaymentIntents, and it never rotates, writes, or exposes a key. It adds an order note by default only when it finds a confirmed mismatch, and it is off by default until you turn DRY_RUN to false.

Related field notes

Citations

On the problem:

  1. Stripe docs: test mode and live mode are separate, and a key only works within its own mode. docs.stripe.com/keys
  2. WooCommerce Stripe gateway docs: where the test and live API keys are entered separately in the settings screen. woocommerce.com/document/stripe
  3. Stripe docs: testing card numbers and how test mode data never appears in live mode. docs.stripe.com/testing

On the solution:

  1. Stripe API: retrieve a PaymentIntent by id, and the error returned for an id from the other mode. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe API: error types and handling InvalidRequestError from the API libraries. docs.stripe.com/api/errors
  3. WooCommerce REST API: read payment gateway settings and orders. woocommerce.github.io/woocommerce-rest-api-docs

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.

Contact me on LinkedIn

Did this save you a launch day?

If this saved you a pile of declined charges or a staging site that quietly charged real cards, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes