Skip to content

Diagnostic Stripe

live charges fail with testmode_decline from test cards

A customer writes in to say their card was declined. You try it yourself with 4242 4242 4242 4242 and it works fine, so you tell them to call their bank. It is not their bank. The charge failed with testmode_decline, which is Stripe saying that something in the request belonged to test mode and the request did not — and the card that “works fine” is the reason you cannot see it.

Read-only key Python and Node.js Tests included
Black imgix server system
Photo by imgix on Unsplash
The short answer

Read GET /v1/charges and count failure_code or outcome.reason equal to testmode_decline, and GET /v1/payment_intents for the same value in last_payment_error.code. Any non-zero count means a test artefact reached production.

Read GET /v1/account as well. If charges_enabled or details_submitted is false, the account was never activated and is restricted to test-mode charges no matter which key you send.

The problem in plain words

Three unrelated mistakes produce the same symptom, which is why this one takes so long to place. A deploy can ship sk_test_… on the server, or ship a live secret key beside a test publishable key so the token created in the browser is never valid for the charge. A seeded price, product or customer ID from the sandbox can be hardcoded in a config file, in which case every live call returns resource_missing with the famously unhelpful “a similar object exists in test mode, but a live mode key was used”. Or the account was never finished, so Stripe accepts the live key and still refuses to take money with it.

The reason it survives testing is that all of your own testing is done with test cards, which behave perfectly in the broken configuration. The failure is only visible to people paying with real cards, and they do not file bug reports — they leave.

Keys pasted intest pair, duringan incidentSuite passes4242 worksperfectlyDeploy shipsnothing lookswrongReal card paystestmode_declineBlamed on bankcustomer leaves
The configuration is only wrong for real cards, and no test in the suite pays with one.

Why it happens

The two modes are separate databases with separate IDs. A test-mode customer ID does not exist in live mode and never will. Stripe is not being pedantic when it rejects it; there is genuinely nothing there. The error message mentions the object existing in the other mode because that is the only useful thing it can say.

Key pairs get split during a rotation. The secret key lives in the server environment and the publishable key lives in the frontend build, and they are usually rotated by different people at different times. A live secret with a test publishable key fails on every real payment and on none of your smoke tests.

Activation is a separate thing from having keys. An account that has not submitted its details still issues live keys and still answers API calls. It just cannot charge anyone, and the condition it reports for that is a decline rather than an error.

The fix, as a flow

The script reads the mode off the key prefix before it reads anything else, then asks the account whether it can charge at all, because both answers change what the decline count is allowed to mean.

Key prefix, GET /v1/accountthen charges and intentsKey is not livere-run with a live keycharges_enabled falseactivation unfinishedtestmode_decline seentest artefact in productionLive account is emptyapp is writing to testLive objects, no declinesmodes are matched
Three unrelated mistakes produce one symptom, so the order of the questions is what tells them apart.

How to fix it

Establish which mode the key is really in

Read it off the prefix before you read anything else. A key containing _live_ is a live key; anything else is test. Half of the time this single line ends the investigation, because the environment variable in production turns out to hold a test key that someone pasted in during an incident and never replaced.

Ask the account whether it can charge at all

GET /v1/account returns charges_enabled and details_submitted. Both must be true. If either is false the account is in the testmode_charges_only condition and the keys are irrelevant — the onboarding is unfinished.

Count the declines that name themselves

Stripe records this condition in three places depending on which object you read: charge.failure_code, charge.outcome.reason, and payment_intent.last_payment_error.code. A check that reads only one of them under-counts, because which fields are populated depends on how far the payment got.

Treat an empty live account as a positive result

If a live key returns zero charges, zero PaymentIntents and zero customers while the business is demonstrably taking orders, nothing is failing in live mode because nothing is happening in live mode. The application is writing to test, and the dashboard everyone is looking at is the test dashboard.

Stop hardcoding IDs that only exist in one mode

Price and product IDs are the usual offenders because they are stable enough to look like configuration. Look them up by lookup_key instead, so the same code resolves to the right object in whichever mode it is running.

How to check it worked

Re-run the script against production. The mode should be live, the account should be activated, and the decline count should be zero.

python3 stripe_live_mode_check.py
# healthy   live objects exist and no testmode_decline in the window

The full code

Four GET requests and no writes. A restricted key with read access to Account, Charges, PaymentIntents and Customers is enough, and is what you should give it. The classification is split into two pure functions: one that knows where Stripe hides this decline, and one that decides what the counts mean.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_live_mode_check.py
"""Report whether a live Stripe key is actually transacting in live mode.

Read only. Four GET requests, no writes: give this a RESTRICTED key with read
access to Account, Charges, PaymentIntents and Customers. The repair is printed,
never performed, because this script holds a credential to a live payments
account.
"""
import argparse
import logging
import os
import sys

import requests

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

API = "https://api.stripe.com/v1"
TESTMODE = "testmode_decline"


def count_testmode_declines(charges, intents):
    """Count objects that failed because a test artefact reached live mode.

    Pure, so the rules can be tested without a network. Stripe records this one
    condition in three different fields depending on how far the payment got: a
    charge carries it as `failure_code` and again as `outcome.reason`, while a
    PaymentIntent that never produced a charge carries it as
    `last_payment_error.code`. Reading only one of them under-counts.
    """
    n = 0
    for c in charges:
        outcome = c.get("outcome") or {}
        if c.get("failure_code") == TESTMODE or outcome.get("reason") == TESTMODE:
            n += 1
    for pi in intents:
        err = pi.get("last_payment_error") or {}
        if err.get("code") == TESTMODE:
            n += 1
    return n


def verdict(key_mode, account, counts):
    """Classify one account. Pure.

    key_mode is "live" or "test", taken from the key prefix. counts holds how
    many objects of each kind this key could see, plus the decline tally.
    """
    if key_mode != "live":
        return ("test_key",
                "this is a test-mode key, so it cannot see the live account at "
                "all: run it again with a restricted live key")
    if not account.get("details_submitted") or not account.get("charges_enabled"):
        return ("not_activated",
                "activation is unfinished, so the account is limited to "
                "test-mode charges: charges_enabled=%s details_submitted=%s"
                % (account.get("charges_enabled"), account.get("details_submitted")))
    if counts.get("testmode_declines"):
        return ("test_cards_live",
                "%d live payment(s) failed with testmode_decline: a test card "
                "number or a test-mode object id reached production"
                % counts["testmode_declines"])
    if not any(counts.get(k, 0) for k in ("charges", "payment_intents", "customers")):
        return ("pointed_at_test",
                "the live account holds no charges, intents or customers: the "
                "application is transacting in test mode")
    return ("healthy", "live objects exist and no testmode_decline in the window")


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
    if r.status_code == 403:
        raise SystemExit("403 from Stripe: the restricted key lacks read access to " + path)
    r.raise_for_status()
    return r.json()


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--limit", type=int, default=100,
                    help="objects to read per resource (1-100)")
    args = ap.parse_args()

    key = os.environ.get("STRIPE_API_KEY")
    if not key:
        log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + key})

    mode = "live" if "_live_" in key else "test"
    account = get(s, "/account")
    charges = get(s, "/charges", limit=args.limit).get("data", [])
    intents = get(s, "/payment_intents", limit=args.limit).get("data", [])
    customers = get(s, "/customers", limit=args.limit).get("data", [])

    counts = {
        "charges": len(charges),
        "payment_intents": len(intents),
        "customers": len(customers),
        "testmode_declines": count_testmode_declines(charges, intents),
    }

    state, detail = verdict(mode, account, counts)
    line = "%-16s %s" % (state, detail)
    if state == "healthy":
        log.info(line)
        return 0

    log.warning(line)
    if state == "test_key":
        log.warning("  repair: export a restricted key beginning rk_live_ and re-run")
        return 2
    if state == "not_activated":
        log.warning("  repair: finish activation at "
                    "https://dashboard.stripe.com/account/onboarding until "
                    "charges_enabled is true")
    else:
        log.warning("  repair: put a matching sk_live_ and pk_live_ pair from the "
                    "same account on server and client")
        log.warning("  repair: remove hardcoded test-mode ids; resolve prices by "
                    "lookup_key so one code path works in both modes")
    log.info("read %d charge(s), %d intent(s), %d customer(s) in %s mode",
             counts["charges"], counts["payment_intents"], counts["customers"], mode)
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-live-mode-check.mjs
/**
 * Report whether a live Stripe key is actually transacting in live mode.
 *
 * Read only. Four GET requests, no writes: give this a RESTRICTED key with read
 * access to Account, Charges, PaymentIntents and Customers. The repair is
 * printed, never performed.
 */
const API = 'https://api.stripe.com/v1';
const TESTMODE = 'testmode_decline';

/**
 * Count objects that failed because a test artefact reached live mode. Pure.
 *
 * Stripe records this one condition in three fields depending on how far the
 * payment got, so reading only one of them under-counts.
 */
export function countTestmodeDeclines(charges, intents) {
  let n = 0;
  for (const c of charges) {
    const outcome = c.outcome ?? {};
    if (c.failure_code === TESTMODE || outcome.reason === TESTMODE) n += 1;
  }
  for (const pi of intents) {
    const err = pi.last_payment_error ?? {};
    if (err.code === TESTMODE) n += 1;
  }
  return n;
}

/**
 * Classify one account. Pure.
 */
export function verdict(keyMode, account, counts) {
  if (keyMode !== 'live') {
    return ['test_key',
      'this is a test-mode key, so it cannot see the live account at all: run ' +
      'it again with a restricted live key'];
  }
  if (!account.details_submitted || !account.charges_enabled) {
    return ['not_activated',
      'activation is unfinished, so the account is limited to test-mode ' +
      `charges: charges_enabled=${account.charges_enabled} ` +
      `details_submitted=${account.details_submitted}`];
  }
  if (counts.testmode_declines) {
    return ['test_cards_live',
      `${counts.testmode_declines} live payment(s) failed with testmode_decline: ` +
      'a test card number or a test-mode object id reached production'];
  }
  const seen = (counts.charges ?? 0) + (counts.payment_intents ?? 0) +
               (counts.customers ?? 0);
  if (!seen) {
    return ['pointed_at_test',
      'the live account holds no charges, intents or customers: the ' +
      'application is transacting in test mode'];
  }
  return ['healthy', 'live objects exist and no testmode_decline in the window'];
}

async function get(key, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
  if (res.status === 401) {
    throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
  }
  if (res.status === 403) {
    throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

async function main() {
  const key = process.env.STRIPE_API_KEY;
  if (!key) {
    console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
    process.exitCode = 2;
    return;
  }

  const mode = key.includes('_live_') ? 'live' : 'test';
  const account = await get(key, '/account');
  const { data: charges = [] } = await get(key, '/charges', { limit: 100 });
  const { data: intents = [] } = await get(key, '/payment_intents', { limit: 100 });
  const { data: customers = [] } = await get(key, '/customers', { limit: 100 });

  const counts = {
    charges: charges.length,
    payment_intents: intents.length,
    customers: customers.length,
    testmode_declines: countTestmodeDeclines(charges, intents),
  };

  const [state, detail] = verdict(mode, account, counts);
  const line = `${state.padEnd(16)} ${detail}`;
  if (state === 'healthy') { console.log(line); return; }

  console.warn(line);
  if (state === 'test_key') {
    console.warn('  repair: export a restricted key beginning rk_live_ and re-run');
    process.exitCode = 2;
    return;
  }
  if (state === 'not_activated') {
    console.warn('  repair: finish activation at ' +
      'https://dashboard.stripe.com/account/onboarding until charges_enabled is true');
  } else {
    console.warn('  repair: put a matching sk_live_ and pk_live_ pair from the ' +
      'same account on server and client');
    console.warn('  repair: remove hardcoded test-mode ids; resolve prices by ' +
      'lookup_key so one code path works in both modes');
  }
  console.log(`read ${counts.charges} charge(s), ${counts.payment_intents} intent(s), ` +
              `${counts.customers} customer(s) in ${mode} mode`);
  process.exitCode = 1;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Two cases carry the note. One is the charge that only reports the condition in outcome.reason and not in failure_code, because a counter that reads a single field is the reason people conclude “no test declines” and go looking elsewhere for a day. The other is the silent one: a live key with an activated account and nothing at all behind it, which is not healthy and has to say so.

test_stripe_live_mode_check.py
from stripe_live_mode_check import count_testmode_declines, verdict

LIVE = {"charges_enabled": True, "details_submitted": True}
BUSY = {"charges": 40, "payment_intents": 40, "customers": 12}


def test_counts_a_charge_that_only_names_it_in_outcome_reason():
    # failure_code is absent here; a counter reading one field misses this.
    charges = [{"outcome": {"reason": "testmode_decline"}}]
    assert count_testmode_declines(charges, []) == 1


def test_counts_an_intent_that_never_produced_a_charge():
    intents = [{"last_payment_error": {"code": "testmode_decline"}}]
    assert count_testmode_declines([], intents) == 1


def test_ordinary_declines_are_not_counted():
    charges = [{"failure_code": "card_declined",
                "outcome": {"reason": "insufficient_funds"}}]
    assert count_testmode_declines(charges, []) == 0


def test_a_test_key_short_circuits_every_other_rule():
    state, detail = verdict("test", {"charges_enabled": False}, {"testmode_declines": 9})
    assert state == "test_key"
    assert "live key" in detail


def test_unactivated_account_outranks_the_decline_count():
    # The declines are real, but the cause is the unfinished onboarding.
    state, _ = verdict("live", {"charges_enabled": False, "details_submitted": True},
                       {"testmode_declines": 3})
    assert state == "not_activated"


def test_declines_on_an_activated_account_name_the_count():
    state, detail = verdict("live", LIVE, dict(BUSY, testmode_declines=3))
    assert state == "test_cards_live"
    assert "3" in detail


def test_an_empty_live_account_is_not_healthy():
    state, _ = verdict("live", LIVE, {"testmode_declines": 0})
    assert state == "pointed_at_test"


def test_busy_and_clean_is_healthy():
    state, _ = verdict("live", LIVE, dict(BUSY, testmode_declines=0))
    assert state == "healthy"
stripe-live-mode-check.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { countTestmodeDeclines, verdict } from './stripe-live-mode-check.mjs';

const LIVE = { charges_enabled: true, details_submitted: true };
const BUSY = { charges: 40, payment_intents: 40, customers: 12 };

test('counts a charge that only names it in outcome.reason', () => {
  assert.equal(countTestmodeDeclines([{ outcome: { reason: 'testmode_decline' } }], []), 1);
});

test('counts an intent that never produced a charge', () => {
  const intents = [{ last_payment_error: { code: 'testmode_decline' } }];
  assert.equal(countTestmodeDeclines([], intents), 1);
});

test('ordinary declines are not counted', () => {
  const charges = [{ failure_code: 'card_declined',
                     outcome: { reason: 'insufficient_funds' } }];
  assert.equal(countTestmodeDeclines(charges, []), 0);
});

test('a test key short circuits every other rule', () => {
  const [state, detail] = verdict('test', { charges_enabled: false },
                                  { testmode_declines: 9 });
  assert.equal(state, 'test_key');
  assert.match(detail, /live key/);
});

test('unactivated account outranks the decline count', () => {
  const [state] = verdict('live', { charges_enabled: false, details_submitted: true },
                          { testmode_declines: 3 });
  assert.equal(state, 'not_activated');
});

test('declines on an activated account name the count', () => {
  const [state, detail] = verdict('live', LIVE, { ...BUSY, testmode_declines: 3 });
  assert.equal(state, 'test_cards_live');
  assert.match(detail, /3/);
});

test('an empty live account is not healthy', () => {
  assert.equal(verdict('live', LIVE, { testmode_declines: 0 })[0], 'pointed_at_test');
});

test('busy and clean is healthy', () => {
  assert.equal(verdict('live', LIVE, { ...BUSY, testmode_declines: 0 })[0], 'healthy');
});

FAQ

What does testmode_decline actually mean?

That something in the request belonged to the other mode. A test card number, a test token, or a test-mode object id was sent with a live key, so Stripe declined it rather than pretending the object existed. It is never the customer's bank.

Why does my live key say a similar object exists in test mode?

Because the id you passed is a test-mode id. The two modes are separate datasets with separate ids, and Stripe adds that sentence because the id is well-formed and does exist, just not where you are looking. It is almost always a price or product id copied out of the sandbox into a config file.

Can this happen even with correct live keys?

Yes, if the account was never activated. charges_enabled and details_submitted stay false until onboarding is finished, and until then the account is restricted to test-mode charges regardless of which key you send. That is why the script reads GET /v1/account before it reads anything else.

Why do my own tests never catch it?

Because you test with test cards, and test cards work perfectly in the broken configuration. The failure is visible only to real cards, which means only to customers. Reading the live decline counts on a schedule is the substitute for a test you cannot write.

Do I need a live secret key to run this check?

No. A restricted live key with read access to Account, Charges, PaymentIntents and Customers is enough. It cannot move money if it leaks, and it can still see everything this note depends on.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.