Decline reasons
The closed decline_reason vocabulary and safe copy to show customers.
decline_reason is a closed, finite vocabulary — every value it can ever carry
is listed on this page. Match on it exhaustively (with a fallback for
redacted) instead of pattern-matching or substring-checking the string:
the set only grows by an explicit change to this catalog, never silently.
Where it appears
decline_reason rides two places, both only ever set on a decline:
Transaction.decline_reason— returned by Get a transaction and List transactions.- the
authorization.decidedwebhook event payload'sdecline_reason.
It's absent on an approval. On a stood-in decline (stip: true — the
authorizer couldn't reach a normal decision in time and fail-closed rather
than risk an unauthorized approval) it's one of the stand-in
tokens below.
Because the set is closed at the wire, not just documented, a decision that carries a reason this catalog hasn't caught up with does not reach you labelled with something unlisted — it's held back for review instead. You will only ever see one of the tokens below.
One field named decline_reason is not part of this vocabulary: the one on
a sandbox simulation record.
It carries raw network wire codes instead — see
Sandbox wire vocabulary before you assert on it.
Card state
Facts about the card itself.
| Token | Meaning | Typical cause | Suggested copy |
|---|---|---|---|
card_not_found | No card matches the identity presented. | A malformed, retired, or nonexistent card number reached the issuer. | "This card can't be used right now. Please check the card details and try again." |
card_inactive | The card exists but hasn't been activated. | The cardholder hasn't completed activation yet. | "This card hasn't been activated yet. Activate it in the app, then try again." |
card_restricted | The card is administratively suspended. | A programme admin or a fraud control suspended the card. | "This card is temporarily restricted. Contact support for help." |
card_closed | The card has been permanently closed. | The cardholder or a programme admin closed the card. | "This card has been closed and can no longer be used." |
card_lost | The card was reported lost. | The cardholder reported the card lost. | "This card has been reported lost. Order a replacement in the app." |
card_stolen | The card was reported stolen. | The cardholder reported the card stolen. | "This card has been reported stolen. Order a replacement in the app." |
card_expiry_mismatch | The expiry submitted with the transaction doesn't match our record. | A mistyped expiry, or a cloned/stale card number. | "We couldn't verify this card's details. Please check the card and try again." |
card_expiry_unknown | We have no expiry on record to check against. | A data gap on our side, never something a cardholder can trigger. | "We couldn't verify this card's details. Please try again or contact support." |
card_expired | The card is past its expiry date. | The card genuinely expired. | "This card has expired. Order a replacement in the app." |
card_currency_unknown | We couldn't establish which currency the card is denominated in, so we declined rather than assume one. | A data gap on our side, never something a cardholder can trigger. | "We're having trouble processing this right now. Please try again in a few minutes." |
programme_config_stale | The card's programme configuration wasn't fresh enough to authorize safely, so we declined rather than guess. | An internal configuration-propagation delay. | "We're having trouble processing this right now. Please try again in a few minutes." |
delegated_auth_unavailable | This programme delegates authorization decisions elsewhere, and that path isn't live yet. | The programme is configured for delegated authorization ahead of that capability shipping. | "This card doesn't support this type of transaction yet." |
programme_ledger_disabled | The programme's ledger module is switched off, so its cards have no funding account to authorize against. | A programme configured for managed authorization with the ledger module off — cards are only given a funding account when it is on. | "This card isn't set up for payments yet. Please contact support." |
card_expiry_mismatch, card_expiry_unknown and card_expired deliberately
share one token family and answer the same way on the network — telling them
apart on the wire would let a stolen card number be probed for its real
expiry a few dozen guesses at a time. Don't build UI that implies they're
distinguishable; the copy above works for all three.
Controls
A programme-level rule matched, or a required allow-list rule didn't.
| Token | Meaning | Typical cause | Suggested copy |
|---|---|---|---|
mcc_denied | The merchant category is explicitly blocked. | A merchant-category deny rule matched. | "This type of purchase isn't allowed on this card." |
mcc_not_allowed | The merchant category isn't on the required allow-list. | An allow-list control didn't include this category. | "This type of purchase isn't allowed on this card." |
country_denied | The transaction's country is explicitly blocked. | A country deny rule matched. | "Purchases from this location aren't allowed on this card." |
country_not_allowed | The transaction's country isn't on the required allow-list. | An allow-list control didn't include this country. | "Purchases from this location aren't allowed on this card." |
channel_denied | This transaction channel (POS, ecommerce, ATM, MOTO) is explicitly blocked. | A channel deny rule matched. | "This type of transaction isn't allowed on this card." |
channel_not_allowed | This channel isn't on the required allow-list. | An allow-list control didn't include this channel. | "This type of transaction isn't allowed on this card." |
entry_mode_denied | The way the card was presented (e.g. a contactless tap) is blocked. | A contactless/entry-mode deny rule matched. | "Tap to pay isn't enabled on this card. Try inserting or swiping instead." |
amount_over_cap | The amount is over this card's per-transaction cap. | A spend-limit control. | "This purchase is over your card's spending limit." |
control_currency_mismatch | Retired — no longer sent. A spend-limit control was set in a currency the transaction wasn't in, so it couldn't be evaluated safely. | Was a configuration issue on the programme's controls. Spend limits are now a plain number in the card's own currency, so this can no longer happen. | "We're having trouble processing this purchase. Please try again or contact support." |
control_currency_mismatch is retired but not removed. No new decline will
carry it: a spend limit is now a plain number denominated by the card, so a
limit and a card can no longer disagree about currency.
It stays listed because this vocabulary only ever grows — a value that disappeared would break any consumer matching it exhaustively, and decisions already recorded with it keep their meaning. Leave your existing branch in place: it is no longer reached for new declines, but a decision recorded before this change still carries it, so anything replaying or reporting over history will keep meeting it.
Velocity
The card hit a rolling window limit.
| Token | Meaning | Typical cause | Suggested copy |
|---|---|---|---|
velocity_count_exceeded | Too many transactions in the tracked window. | The card's transaction-count limit was reached. | "You've made too many purchases in a short time. Please try again later." |
velocity_amount_exceeded | Too much spent in the tracked window. | The card's spend-amount limit was reached. | "You've reached your spending limit for this period." |
Funding
The ledger side of the decision — whether the money is there and postable.
cross_currency is the one token listed here that can also be answered
earlier, straight from the card record and before any ledger call. The
meaning, the network response code and the copy are identical either way, so
nothing consuming it needs to tell the two apart.
| Token | Meaning | Typical cause | Suggested copy |
|---|---|---|---|
insufficient_funds | Not enough available balance for the amount. | A genuine shortfall on the funding account. | "Insufficient funds. Add funds to your account and try again." |
cross_currency | The transaction currency doesn't match the currency the card is denominated in. | A foreign-currency purchase on a single-currency card. | "This card can't be used for purchases in this currency." |
refund_not_supported | Refunds aren't processed on this rail yet. | A merchant attempted to credit the cardholder. | "Refunds aren't currently supported for this card." |
balance_inquiry_not_supported | Balance-inquiry transactions aren't processed. | An ATM or terminal balance check was attempted. | "Balance inquiries aren't supported for this card." |
authorization_conflict | The same transaction identity was submitted twice with different content. | A retried request that changed something about the original. | "Something went wrong processing this purchase. Please try again." |
funding_account_missing | This card has no funding account configured. | A provisioning gap. | "This card isn't ready to use yet. Please try again shortly or contact support." |
funding_account_not_balance_checked | The funding account's overdraft posture means it isn't balance-checked. | An internal provisioning/account-configuration state. | "We're having trouble processing this purchase. Please try again or contact support." |
funding_account_limit_unknown | The account is balance-checked but its overdraft ceiling is unbounded or couldn't be established. | An internal provisioning or data gap — never a cardholder condition. | "We're having trouble processing this purchase. Please try again or contact support." |
funding_account_not_found | The funding account record referenced by the card doesn't exist. | An internal data inconsistency. | "We're having trouble processing this purchase. Please try again or contact support." |
verification_amount_mismatch | A zero-amount account-verification request's flag didn't match its amount. | A producer/request inconsistency, not reachable from normal card use. | "We're having trouble processing this purchase. Please try again or contact support." |
zero_amount_not_declared | A zero-amount request wasn't flagged as a verification. | Same as above, the other direction. | "We're having trouble processing this purchase. Please try again or contact support." |
amount_precision_unsupported | The amount carries more decimal places than the currency has minor units. | A caller sent a sub-cent amount — e.g. 10.005 in EUR. Terminals cannot produce this; it reaches us from an API caller, the simulator or the console. | "We couldn't process that amount. Please try again with a whole number of cents." |
refund_float_not_configured | The programme has no account configured to draw refund credits from. | A provisioning gap: refunds were attempted on a programme whose refund float was never set. Not a cardholder condition. | "We couldn't process this refund. Please contact support." |
refund_float_exhausted | The programme's refund float has no funds left to credit from. | The float needs topping up — every refund on the programme declines until it is. The cardholder is being credited here, so nothing about their balance is the cause. | "We couldn't process this refund right now. Please contact support." |
refund_posting_unconfirmed | A refund credit was submitted to the ledger and we could not confirm whether it posted. | An internal fault on our side. Retrying the same message will not resolve it — the recorded decision is replayed — so it needs a reconciler, not a retry. | "We couldn't confirm this refund. Please contact support before trying again." |
funding_request_invalid | The ledger rejected the hold request for an unclassified reason. | An internal inconsistency between the authorizer and the ledger. | "Something went wrong processing this purchase. Please try again." |
hold_state_unverifiable | A replayed transaction's hold state couldn't be confirmed. | An internal inconsistency during a retried/replayed decision. | "Something went wrong processing this purchase. Please try again." |
hold_amount_mismatch | A replayed transaction's hold was sized differently than expected. | An internal inconsistency during a retried/replayed decision. | "Something went wrong processing this purchase. Please try again." |
hold_not_active | A hold this transaction needed is no longer active. | An internal inconsistency during a retried/replayed decision. | "Something went wrong processing this purchase. Please try again." |
sandbox_control_live_mode | A test-mode-only control was invoked outside test mode. | Test configuration reached a live-mode request — not reachable in production use. | "Something went wrong processing this purchase. Please try again or contact support." |
sandbox_partial_invalid | A malformed sandbox partial-approval amount. | Test-only tooling misuse — not reachable in production use. | "Something went wrong processing this purchase. Please try again or contact support." |
Engine
Not a fact about the card, the cardholder, the funds, or a stand-in — the decision pipeline itself misbehaved.
| Token | Meaning | Typical cause | Suggested copy |
|---|---|---|---|
pipeline_incomplete | The decision pipeline reached the end of its stage list without any stage settling a verdict. | A wiring bug in the authorizer, not something a request can trigger deliberately. | "Something went wrong processing this purchase. Please try again or contact support." |
Stand-in (stip_*)
When the authorizer can't reach a normal decision before its deadline —
a dependency is down, the request budget ran out, or its own configuration
isn't warm — it fails closed with stip: true rather than risk an
unauthorized approval. decline_reason on a stand-in decline is always
prefixed stip_, and every value below is one this build emits today:
| Token | Meaning |
|---|---|
stip_config_cold | Stood in because the programme's configuration wasn't ready. |
stip_idempotency_in_flight | Stood in because an identical request was already being processed concurrently. |
stip_ledger_unavailable | Stood in because the ledger couldn't be reached. |
stip_controls_timeout | Stood in because the controls check ran out of time. |
stip_dependency_unavailable | Stood in because a required dependency was unreachable. |
stip_deadline_in_past | Stood in because the request had already missed its processing deadline on arrival. |
stip_deadline_exceeded | Stood in because processing ran past its allotted time budget. |
stip_card_unresolved | Stood in because the card couldn't be resolved before the deadline. |
Every stand-in reason answers the same for a consumer — it's a transient, our-side failure, not a fact about the card, the cardholder, or the funds:
"We couldn't process this purchase right now. Please try again in a moment."
redacted
A reason existed on the decision, but its detail wasn't safe to publish verbatim — or, on a build ahead of this catalog, wasn't in this vocabulary at all. Treat it exactly like any other decline with no more specific information available:
"We couldn't complete this purchase. Please try again or contact support."
Building consumer copy
Match on decline_reason exhaustively, with redacted — and the case where
decline_reason is absent on a declined transaction — folding into the same
generic fallback. That combination is what the closed vocabulary buys you
over substring-matching the field: every branch you write is a real, listed
token, and anything the switch doesn't recognise is provably out of date
rather than a token you can't reproduce.
const COPY: Partial<Record<string, string>> = {
insufficient_funds: "Insufficient funds. Add funds to your account and try again.",
card_expired: "This card has expired. Order a replacement in the app.",
amount_over_cap: "This purchase is over your card's spending limit.",
// ...the rest of the table above.
};
const FALLBACK = "We couldn't complete this purchase. Please try again or contact support.";
function copyForDecline(declineReason: string | undefined): string {
if (declineReason === undefined) return FALLBACK;
return COPY[declineReason] ?? FALLBACK;
}Don't infer meaning from a decline reason's shape (a _ count, a prefix
other than stip_, or its length) — new tokens are added to categories over
time, and a shape-based guess breaks silently the day that happens. Read the
full event catalog for how decline_reason sits
inside the rest of the authorization.decided payload.
Sandbox wire vocabulary
There are two decline surfaces, and they speak different languages.
Transactions and webhooks — everything above — publish the closed vocabulary:
a token that names the decision (mcc_denied, entry_mode_denied,
card_lost). It is a contract. It is finite, it is reviewed, and a reason
outside it never reaches you.
Simulation records —
GET /v1/simulated-spends/{id}
— publish something else entirely: the raw network wire code, translated
only as far as its standard ISO 8583 meaning. The sandbox simulator sits on the
far side of the wire from the authorization engine and deliberately never
learns why a decision was made; all it can see is the DE39 response code that
came back, so all it reports is what that code means (do_not_honor,
lost_card, stolen_card, insufficient_funds,
not_permitted_to_cardholder, and so on — an unrecognised code is passed
through as declined_<code>). That set is open-ended by design: it tracks the
network, not our catalog.
Two consequences worth internalising before you write an assertion against a simulation record:
The mapping is many-to-one. A single wire code carries several distinct
published reasons. mcc_denied and entry_mode_denied both leave the engine
as DE39 57, so the simulation record for either one reads
not_permitted_to_cardholder. You cannot recover which control actually fired
from the record's decline_reason — read the transaction or the
authorization.decided event for that.
The spellings near-miss. The wire token for a lost card is lost_card. The
published token is card_lost. Same two words, opposite order,
different surfaces — and a switch written against one vocabulary will fall
straight through to its default on the other while looking perfectly correct in
review. This is the single most likely way to get this wrong.
So: to assert on the decision in an integration test or a verification
runbook, read the transaction or the authorization.decided event and match
against the vocabulary on this page. Read a simulation record's
decline_reason only for what it is — evidence of what came back over the
simulated wire — and never match it against the tokens above.