Skip to content

Diagnostic Stripe

off-session charges die on authentication_required

A renewal fails. The customer's card is fine, it has not expired, it has money on it, and it worked when they first signed up. You retry off-session and it fails identically, so you retry again on a schedule and it fails identically again. The decline_code is authentication_required, and no number of retries will ever change it.

Read-only key Python and Node.js Tests included
A woman sitting at a table looking at her cell phone
Photo by Vitaly Gariev on Unsplash
The short answer

Read GET /v1/payment_intents and count last_payment_error.decline_code in authentication_required or authentication_not_handled. Then, for each affected customer, read GET /v1/setup_intents?customer={id} and look for one with status of "succeeded" and a non-null mandate.

No such SetupIntent means the card was attached directly, no mandate was ever recorded, and the issuer will soft-decline every off-session attempt. The fix is a new on-session authentication, not a retry.

The problem in plain words

Under SCA, a merchant-initiated off-session charge is only exempt from authentication if the card was authenticated on-session at the time it was saved, and a mandate exists recording that the customer agreed to be charged later. Attaching a payment method with a bare POST /v1/payment_methods/{id}/attach saves the card and records none of that. The card is stored, it looks correct in the Dashboard, and it is not chargeable off-session.

It passes every test you write, because the first charge is usually on-session and on-session charges work. The damage appears one billing cycle later, on renewals, on delayed captures, on the second invoice — at which point it looks like a dunning problem rather than a card-saving problem, and dunning is where teams spend the next two weeks.

Stripe names the same condition differently depending on where you meet it. On the invoice path it surfaces as billing_invalid_mandate; on the intent it is authentication_required. That the two are one bug is not obvious from either message.

Card attachedbare attach callNo mandaterecordednothing to proveconsentFirst chargeworkscustomer waspresentRenewaldeclinesauthentication_requiredRetries repeatidentical answerevery retry declines identically
The first charge is on session and works, so the bug is invisible until the next billing cycle.

Why it happens

Retrying cannot help. The issuer is asking for a customer who is not there. Every off-session retry presents the same missing authentication and receives the same soft decline, which is why this failure has a completely flat retry curve while a genuine insufficient-funds decline does not.

The generic code hides the specific one. last_payment_error.code is card_declined on all of these, exactly like an ordinary decline. The distinguishing value is one level down in decline_code, so a dashboard that groups by code shows this as noise inside the normal decline rate.

Attaching is the obvious call and the wrong one. attach reads like the API for saving a card, and it is — for cards that will only ever be charged with the customer present. Off-session billing needs a SetupIntent with usage=off_session, or a payment created with setup_future_usage=off_session, and neither is discoverable from the name of the endpoint you reached for first.

The fix, as a flow

The script groups declines by customer before it asks about mandates, because the mandate is a property of how one card was saved and six failed renewals for one customer are a single problem.

Declines per customerplus GET /v1/setup_intentsDeclines, no mandatere authenticate the cardDeclines, mandate on filefinish this one on sessionNo declines, no mandatewill fail at renewalSaved cards, mandate on filechargeable off session
A decline without a mandate is a card saving bug. A decline with one is the issuer stepping up, and the repairs differ.

How to fix it

Find the declines by decline_code, not code

GET /v1/payment_intents?limit=100&created[gte]={now-90d}, paginated, filtering last_payment_error.decline_code. Ninety days covers at least two monthly cycles, which is what separates a customer this happened to once from a customer it happens to every month.

Group by customer, not by intent

The mandate is a property of how a card was saved for a customer, so one customer with six failed renewals is one problem, not six. Grouping first also keeps the second call cheap.

Ask whether a mandate was ever produced

GET /v1/setup_intents?customer={id}. A SetupIntent that was created and abandoned proves nothing. Only one that reached succeeded and carries a non-null mandate records that the customer authenticated and agreed to future charges.

Separate the two failures that look the same

A decline with no mandate is a saving bug and needs re-authentication. A decline with a mandate is the issuer stepping up anyway, which happens, and that one is finished by bringing the customer back on-session for this single charge. Retrying is wrong in both cases but for different reasons.

Look at customers who have not failed yet

Saved cards with no mandate behind them have not failed because they have not been charged off-session yet. Everyone on that list will fail at their next renewal, and there is a window to email them a SetupIntent link before it happens.

How to check it worked

Re-run the script after the re-authentication emails have gone out. Customers should move from unmandated and at_risk into covered.

python3 stripe_offsession_mandates.py --days 90
# 412 customer(s), 0 unmandated, 0 at risk

The full code

Two kinds of GET request and no writes: a restricted key with read access to PaymentIntents, SetupIntents and PaymentMethods is enough. The classification is three pure functions because there are three separate judgements — what counts as this decline, what counts as a mandate, and what the combination means for one customer.

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_offsession_mandates.py
"""Report customers whose saved cards cannot be charged off-session.

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

import requests

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

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

# Both mean the issuer wanted the cardholder present. The second is what Stripe
# returns when the integration never handled the step-up at all.
STEP_UP = ("authentication_required", "authentication_not_handled")


def is_step_up_decline(intent):
    """True when this intent failed for want of authentication. Pure.

    The generic `code` on these is `card_declined`, identical to an ordinary
    decline, so the distinguishing value is one level down in `decline_code`.
    Anything grouping by `code` buries this failure inside the normal decline
    rate.
    """
    err = intent.get("last_payment_error") or {}
    return err.get("decline_code") in STEP_UP


def has_mandate(setup_intents):
    """True when some SetupIntent for this customer actually produced a mandate.

    Pure. A SetupIntent that was created and abandoned proves nothing: only one
    that reached `succeeded` and carries a non-null `mandate` records that the
    customer authenticated on-session and agreed to be charged later.
    """
    return any(si.get("status") == "succeeded" and si.get("mandate")
               for si in setup_intents)


def verdict(declines, saved_cards, setup_intents):
    """Classify one customer. Pure.

    Two states share a symptom and need different repairs: a decline with no
    mandate is a card-saving bug, and a decline despite a mandate is the issuer
    stepping up anyway. Retrying off-session is wrong for both.
    """
    mandated = has_mandate(setup_intents)
    if declines and not mandated:
        return ("unmandated",
                "%d off-session decline(s) and no succeeded SetupIntent carrying "
                "a mandate: the card was attached directly, so every retry "
                "declines identically" % declines)
    if declines:
        return ("stepped_up",
                "%d off-session decline(s) despite a mandate on file: the issuer "
                "asked for the cardholder anyway, so this charge has to be "
                "finished on-session" % declines)
    if saved_cards and not mandated:
        return ("at_risk",
                "%d saved card(s) with no mandate behind them: nothing has failed "
                "yet only because nothing has been charged off-session yet"
                % saved_cards)
    if saved_cards:
        return ("covered", "saved cards are backed by a mandate")
    return ("clear", "no saved cards to charge off-session")


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 declines_by_customer(session, since, cap):
    """Tally step-up declines per customer id over the window."""
    counts = collections.Counter()
    seen = 0
    params = {"limit": 100, "created[gte]": since}
    while True:
        page = get(session, "/payment_intents", **params)
        rows = page.get("data", [])
        for pi in rows:
            seen += 1
            if pi.get("customer") and is_step_up_decline(pi):
                counts[pi["customer"]] += 1
        if not rows or not page.get("has_more") or seen >= cap:
            break
        params["starting_after"] = rows[-1]["id"]
    return counts, seen


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=90,
                    help="how far back to look for declines")
    ap.add_argument("--max-intents", type=int, default=5000,
                    help="stop sampling after this many intents")
    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})

    since = int(time.time()) - args.days * 86400
    declines, sampled = declines_by_customer(s, since, args.max_intents)
    if not declines:
        log.info("sampled %d intent(s), no authentication_required declines", sampled)
        return 0

    unmandated = stepped_up = 0
    for customer, n in declines.most_common():
        sis = get(s, "/setup_intents", customer=customer, limit=100).get("data", [])
        cards = get(s, "/payment_methods", customer=customer, type="card",
                    limit=100).get("data", [])
        state, detail = verdict(n, len(cards), sis)
        log.warning("%-11s %s  %s", state, customer, detail)
        if state == "unmandated":
            unmandated += 1
            log.warning("  repair: send a SetupIntent link so the customer "
                        "re-authenticates, then charge with off_session=true "
                        "and confirm=true")
        elif state == "stepped_up":
            stepped_up += 1
            log.warning("  repair: bring the customer back on-session for this "
                        "charge; do not schedule another off-session retry")

    log.warning("  repair: stop attaching cards directly. Save with a SetupIntent "
                "using usage=off_session, or setup_future_usage=off_session during "
                "a payment")
    log.info("%d customer(s) declining: %d unmandated, %d stepped up",
             len(declines), unmandated, stepped_up)
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-offsession-mandates.mjs
/**
 * Report customers whose saved cards cannot be charged off-session.
 *
 * Read only. GET requests only, no writes: give this a RESTRICTED key with read
 * access to PaymentIntents, SetupIntents and PaymentMethods. The repair is
 * printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// Both mean the issuer wanted the cardholder present. The second is what Stripe
// returns when the integration never handled the step-up at all.
const STEP_UP = new Set(['authentication_required', 'authentication_not_handled']);

/**
 * True when this intent failed for want of authentication. Pure.
 *
 * The generic `code` is `card_declined` on all of these, so the distinguishing
 * value is one level down in `decline_code`.
 */
export function isStepUpDecline(intent) {
  const err = intent.last_payment_error ?? {};
  return STEP_UP.has(err.decline_code);
}

/**
 * True when some SetupIntent for this customer actually produced a mandate. Pure.
 *
 * An abandoned SetupIntent proves nothing: it has to have succeeded and to carry
 * a non-null mandate.
 */
export function hasMandate(setupIntents) {
  return setupIntents.some((si) => si.status === 'succeeded' && si.mandate);
}

/**
 * Classify one customer. Pure.
 */
export function verdict(declines, savedCards, setupIntents) {
  const mandated = hasMandate(setupIntents);
  if (declines && !mandated) {
    return ['unmandated',
      `${declines} off-session decline(s) and no succeeded SetupIntent carrying ` +
      'a mandate: the card was attached directly, so every retry declines identically'];
  }
  if (declines) {
    return ['stepped_up',
      `${declines} off-session decline(s) despite a mandate on file: the issuer ` +
      'asked for the cardholder anyway, so this charge has to be finished on-session'];
  }
  if (savedCards && !mandated) {
    return ['at_risk',
      `${savedCards} saved card(s) with no mandate behind them: nothing has ` +
      'failed yet only because nothing has been charged off-session yet'];
  }
  if (savedCards) return ['covered', 'saved cards are backed by a mandate'];
  return ['clear', 'no saved cards to charge off-session'];
}

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();
}

export async function declinesByCustomer(key, since, cap = 5000) {
  const counts = new Map();
  let seen = 0;
  const params = { limit: 100, 'created[gte]': since };
  for (;;) {
    const page = await get(key, '/payment_intents', params);
    const rows = page.data ?? [];
    for (const pi of rows) {
      seen += 1;
      if (pi.customer && isStepUpDecline(pi)) {
        counts.set(pi.customer, (counts.get(pi.customer) ?? 0) + 1);
      }
    }
    if (!rows.length || !page.has_more || seen >= cap) break;
    params.starting_after = rows[rows.length - 1].id;
  }
  return { counts, seen };
}

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 since = Math.floor(Date.now() / 1000) - 90 * 86400;
  const { counts, seen } = await declinesByCustomer(key, since);
  if (counts.size === 0) {
    console.log(`sampled ${seen} intent(s), no authentication_required declines`);
    return;
  }

  let unmandated = 0;
  let steppedUp = 0;
  for (const [customer, n] of [...counts].sort((a, b) => b[1] - a[1])) {
    const { data: sis = [] } = await get(key, '/setup_intents',
                                         { customer, limit: 100 });
    const { data: cards = [] } = await get(key, '/payment_methods',
                                           { customer, type: 'card', limit: 100 });
    const [state, detail] = verdict(n, cards.length, sis);
    console.warn(`${state.padEnd(11)} ${customer}  ${detail}`);
    if (state === 'unmandated') {
      unmandated += 1;
      console.warn('  repair: send a SetupIntent link so the customer ' +
        're-authenticates, then charge with off_session=true and confirm=true');
    } else if (state === 'stepped_up') {
      steppedUp += 1;
      console.warn('  repair: bring the customer back on-session for this charge; ' +
        'do not schedule another off-session retry');
    }
  }

  console.warn('  repair: stop attaching cards directly. Save with a SetupIntent ' +
    'using usage=off_session, or setup_future_usage=off_session during a payment');
  console.log(`${counts.size} customer(s) declining: ${unmandated} unmandated, ` +
              `${steppedUp} stepped up`);
  process.exitCode = 1;
}

// Only run when invoked directly, so importing this module from the test file
// does not execute main() and fail the suite on the missing key.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The case worth pinning hardest is the SetupIntent that succeeded with a null mandate. It is present, it is green in the Dashboard, and it does not make the card chargeable off-session — treating its existence as proof is how a check reports every affected customer as healthy. The other is at_risk: no declines yet, no mandate either, which is the only state where there is still time to act before a renewal fails.

test_stripe_offsession_mandates.py
from stripe_offsession_mandates import is_step_up_decline, has_mandate, verdict

GOOD_SI = {"status": "succeeded", "mandate": "mandate_123"}


def test_authentication_required_is_a_step_up_decline():
    assert is_step_up_decline({"last_payment_error":
                               {"code": "card_declined",
                                "decline_code": "authentication_required"}})


def test_authentication_not_handled_counts_too():
    assert is_step_up_decline({"last_payment_error":
                               {"decline_code": "authentication_not_handled"}})


def test_an_ordinary_decline_is_not_one():
    assert not is_step_up_decline({"last_payment_error":
                                   {"code": "card_declined",
                                    "decline_code": "insufficient_funds"}})


def test_a_succeeded_setup_intent_without_a_mandate_is_not_proof():
    # Green in the Dashboard, still not chargeable off-session.
    assert not has_mandate([{"status": "succeeded", "mandate": None}])


def test_an_abandoned_setup_intent_is_not_proof():
    assert not has_mandate([{"status": "requires_confirmation", "mandate": None}])


def test_declines_without_a_mandate_are_a_card_saving_bug():
    state, detail = verdict(4, 1, [{"status": "succeeded", "mandate": None}])
    assert state == "unmandated"
    assert "4" in detail


def test_declines_with_a_mandate_are_the_issuer_stepping_up():
    state, detail = verdict(2, 1, [GOOD_SI])
    assert state == "stepped_up"
    assert "on-session" in detail


def test_saved_cards_with_no_mandate_and_no_declines_yet_are_at_risk():
    state, _ = verdict(0, 3, [])
    assert state == "at_risk"


def test_saved_cards_behind_a_mandate_are_covered():
    assert verdict(0, 2, [GOOD_SI])[0] == "covered"


def test_a_customer_with_no_saved_cards_is_clear():
    assert verdict(0, 0, [])[0] == "clear"
stripe-offsession-mandates.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isStepUpDecline, hasMandate, verdict } from './stripe-offsession-mandates.mjs';

const GOOD_SI = { status: 'succeeded', mandate: 'mandate_123' };

test('authentication_required is a step up decline', () => {
  assert.equal(isStepUpDecline({
    last_payment_error: { code: 'card_declined',
                          decline_code: 'authentication_required' },
  }), true);
});

test('authentication_not_handled counts too', () => {
  assert.equal(isStepUpDecline({
    last_payment_error: { decline_code: 'authentication_not_handled' },
  }), true);
});

test('an ordinary decline is not one', () => {
  assert.equal(isStepUpDecline({
    last_payment_error: { code: 'card_declined', decline_code: 'insufficient_funds' },
  }), false);
});

test('a succeeded setup intent without a mandate is not proof', () => {
  assert.equal(hasMandate([{ status: 'succeeded', mandate: null }]), false);
});

test('an abandoned setup intent is not proof', () => {
  assert.equal(hasMandate([{ status: 'requires_confirmation', mandate: null }]), false);
});

test('declines without a mandate are a card saving bug', () => {
  const [state, detail] = verdict(4, 1, [{ status: 'succeeded', mandate: null }]);
  assert.equal(state, 'unmandated');
  assert.match(detail, /4/);
});

test('declines with a mandate are the issuer stepping up', () => {
  const [state, detail] = verdict(2, 1, [GOOD_SI]);
  assert.equal(state, 'stepped_up');
  assert.match(detail, /on-session/);
});

test('saved cards with no mandate and no declines yet are at risk', () => {
  assert.equal(verdict(0, 3, [])[0], 'at_risk');
});

test('saved cards behind a mandate are covered', () => {
  assert.equal(verdict(0, 2, [GOOD_SI])[0], 'covered');
});

test('a customer with no saved cards is clear', () => {
  assert.equal(verdict(0, 0, [])[0], 'clear');
});

FAQ

What does decline_code authentication_required mean?

That the issuer wants the cardholder present and there is nobody there. Off-session charges are only exempt from authentication when the card was authenticated on-session at the time it was saved and a mandate was recorded; without that, the issuer soft-declines.

Why does retrying never work?

Because nothing about the attempt changes. Every retry presents the same missing authentication and gets the same answer, which is why these declines have a completely flat retry curve compared with insufficient funds. The card has to be re-authenticated by the customer.

How do I know whether a mandate exists?

GET /v1/setup_intents?customer={id} and look for one with status succeeded and a non-null mandate. Both conditions matter: a SetupIntent can succeed and still carry no mandate, and that one does not make the card chargeable off-session.

Is billing_invalid_mandate the same problem?

Yes, seen from the invoice side. Stripe reports the missing mandate as billing_invalid_mandate on subscription and invoice paths and as authentication_required on the intent itself, but the cause and the fix are identical.

How should cards be saved for later billing?

With a SetupIntent using usage=off_session, confirmed on-session so a mandate is generated, or during a payment with setup_future_usage=off_session. Not with a bare attach call, which stores the card and records none of the consent that off-session charging 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.