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.
The same two surfaces also carry a decision's evidence trace, and there the split is sharper than a difference in location:
Decision evidence appears under two names by surface convention: the REST transaction carries
trace(camelCase entries,durationMs); theauthorization.decidedwebhook event carriesdecision_trace(snake_case,duration_ms). They are the same provenance — pin your parser to the surface you consume, not the other one's field name.
Card state
Facts about the card itself.
| Token | Meaning | Typical cause | Suggested copy |
|---|---|---|---|
card_not_found | No card matches the identity presented. You never receive this value: a card we can't find belongs to no tenant, so no transaction and no authorization.decided event is created to carry it. It is listed only because the vocabulary is closed. | A malformed, retired, or nonexistent card number reached the issuer. | None needed — this value never reaches you. |
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 decides its own authorizations, and we couldn't reach that decision at all. | Either the delegated path isn't switched on for the programme yet; or its endpoint isn't fully provisioned — no signing secret, no stand-in policy, or no URL registered; or we refused to call the URL you registered. That last one is the cause to check first on a programme that is already live: we won't send an authorization to an address that resolves inside a private network, to a hostname whose DNS answer changes between our check and the call, or to a certificate that doesn't match the one pinned for you. The trace records delegated:blocked. Because a refused URL is a configuration fault rather than an outage, your stand-in policy is deliberately not consulted — we never approve a transaction we can't prove reached you. | "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." |
delegated_declined | Your own authorization endpoint declined this transaction. | You decide authorizations for this programme, and your endpoint answered "decline". Your own decline_reason is never put on the card network in your name, and it does not appear on the trace you can query: the published trace records delegated:decline and stops there. A reason that is a single token starting with a lowercase letter, then up to 47 more lowercase letters, digits or underscores (for example insufficient_funds) is retained on our internal record, where support can quote it back to you. Anything else — prose, punctuation, capitals, or a leading digit — is discarded rather than reshaped, and our internal record then reads delegated:decline, indistinguishable from a decline that carried no reason at all. Your decline still stands exactly as you sent it. Because we keep no marker for a discarded reason, support cannot confirm whether your endpoint populated the field: treat your own logs, not ours, as the place your decline reasons are readable. | "This payment was declined. Please contact your card issuer." |
delegated_response_invalid | Your endpoint answered something we couldn't relay to the acquirer, so we declined rather than guess. | A full approve carrying an approved_amount that isn't an echo of the amount we asked about; a partial approval on a transaction whose terminal never offered partial approval, or on a channel other than card-present POS; a partial in a different currency from the transaction; or a partial that isn't below the amount requested, including one with more decimal places than the currency has. Note that a partial with a missing, zero or negative approved_amount is not in this set — that body fails validation outright, which we treat as no answer at all and hand to your stand-in policy, so it can end in an approval. | "We couldn't complete this payment. Please try again or use another card." |
delegated_verification_unsupported | We don't currently ask your endpoint about account verifications, so we decline them rather than guess your answer. | An account verification — a zero-amount "is this card good?" check — on a programme that decides its own authorizations. The webhook payload we send you has no way to say "this is a verification, not a payment", and we won't send you a zero-amount authorization that looks like one: you'd be approving something with no amount and no movement behind it. Rather than answer on your behalf, we decline. Note this differs from programmes we authorize for, where a verification is approved on card validity alone. If you need verifications on a delegated programme, tell us — it needs a new field on the webhook payload and an agreement about what your approval would mean. | "We couldn't verify this card right now. Please try a different card." |
delegated_stand_in_declined | Your endpoint couldn't be reached, so we stood in on your behalf — and the answer was a decline. Usually that means your stand-in policy refused it, though cause (7) below declines before the policy is consulted at all. | Your endpoint timed out, refused the connection, or answered unusably, and then one of these happened: (1) the transaction exceeded the flat per-transaction cap in your stored stand-in policy, or hit an MCC it denies; (2) your programme's cumulative exposure budget for this outage is spent — we add up every stand-in approved while you're unreachable and stop at max_total_exposure, so a long outage declines everything once the budget runs out and keeps declining until your endpoint answers again, at which point the session closes and the sum resets — note that changing the policy's declared currency mid-outage also resets it, starting a fresh budget under the new currency; (3) no balance has ever been declared for this card — a stand-in cannot approve against a ceiling that was never set, so an undeclared card auto-declines rather than falling back to the flat programme cap alone; (4) a balance was declared, but it's below this transaction's amount — the common case once you're declaring balances day to day, and the one to check first if a card that usually approves suddenly doesn't; (5) the transaction's currency doesn't match the one your exposure budget is denominated in — we decline rather than convert; (6) the declared-balance store or the exposure counter itself couldn't be read, which fails closed rather than approving blind; or (7) the amount carries more decimal places than its currency has minor units (19.999 EUR, say) — a stand-in cannot express it on the wire, so it declines before your policy is consulted at all. Cause (7) is the one to suspect when every other explanation is demonstrably false: caps present and unspent, balance declared and sufficient, currencies matching, nothing unavailable. A policy missing either max_amount or max_total_exposure cannot stand in at all — both are optional on the wire, so a policy carrying only one stores successfully and then declines everything (incomplete_policy) during your first real outage; check both before looking anywhere else. See Stand-in and declared balances for how the three ceilings (per-transaction cap, outage exposure budget, and declared balance) interact — including who bears the loss when a stand-in approval turns out unfunded. The decision is flagged stip: true. | "This payment was declined. Please try again shortly." |
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." |
cvv2_mismatch | The card verification value the merchant collected doesn't match. | A presented CVV2 failed verification. | "The security code (CVV) doesn't match. Please check the back of your card and try again." |
cvv2_verify_error | We couldn't check the card verification value — the check itself failed on our side, not the code. | Our verification service was unreachable, too slow, or answered unusably. This is our fault, not the cardholder's, and the security code may well have been correct. Unlike cvv2_mismatch it is safe to retry: a later attempt will usually succeed once the check recovers. | "Something went wrong on our end. Please try again in a moment." |
cavv_mismatch | The 3-D Secure authentication value (CAVV) presented with the authorization failed cryptographic verification. | A presented CAVV didn't verify. Treated as potential replay or tampering, not a missing authentication — retrying without a fresh 3-D Secure authentication won't succeed. | "This payment couldn't be verified. Please try again with 3-D Secure." |
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." |
Fraud and risk
A risk verdict about this transaction rather than a rule it broke. Both tokens are deliberately unspecific about what was noticed — a decline that named the signal it tripped would tell someone testing cards exactly what to change.
| Token | Meaning | Typical cause | Suggested copy |
|---|---|---|---|
fraud_suspected | Risk scoring judged this transaction too likely to be fraudulent to approve. | Several weak signals together, not one rule — so this says nothing specific about the card, the cardholder or the merchant, and a later transaction on the same card can well be approved. | "This purchase couldn't be approved. Please contact your card issuer if you'd like to discuss it." |
cvv2_attempts_exceeded | Too many failed security-code (CVV2) attempts on this card recently, so the card is temporarily refused. | Repeated wrong security codes — a mistyped card, or someone guessing. The pause covers every purchase on the card, not only the ones that ask for a security code (see below). It lifts by itself once the window passes; it is not a card status change, and nothing about the card needs to be reissued. | "For your security we've paused purchases on this card for a short while. Please try again later." |
While that pause is in force, every transaction on the card gets
cvv2_attempts_exceeded — including one presenting the correct security
code. That is deliberate: an attempt that broke through the pause would tell a
guesser they had found the right code.
These two are produced by the platform's risk engine, which scores the authorizations we decide once the card, control and velocity checks have passed. On a programme that decides its own authorizations (Gateway mode), it is skipped before it reads anything — the decision is yours, so scoring it would spend your deadline on a verdict nobody would consult — and neither token can reach you. They were listed here before the first decision could carry one — for the reason this page's opening paragraph gives: the vocabulary is closed at the wire, so a consumer matching it exhaustively is better served by a token it has an arm for and has not yet seen than by one that shows up undocumented.
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." |
single_message_product_mismatch | A single-message (0200) transaction arrived for a card whose programme isn't configured to receive single-message traffic. | The wire says single-message, but the programme's own configuration says dual-message, or is unclassified. This is a routing/configuration anomaly worth an operator's attention on this specific product — not something the transaction itself did wrong. | "We couldn't complete this transaction. Please contact support." |
single_message_not_recognized | A single-message transaction for a properly single-message-configured product wasn't recognized as one we can post directly. | This build doesn't yet recognize the specific DE3/Business Application Identifier pair needed to confirm this is a real incoming credit we can post (for example, a Visa Direct Original Credit Transaction) — the recognized-pairs table is empty pending scheme certification data, so this is the expected outcome for single-message traffic today, not an anomaly worth investigating per instance. | "We couldn't complete this transaction. Please contact support." |
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 is not forbid, so this platform declines rather than spending against it. The reason's name is misleading and kept only because it is published: such an account is balance-checked by the ledger — against its posted balance and its own credit limit — but that limit is not one the authorization path can see, so it refuses rather than approving against a ceiling it does not know. | An internal provisioning/account-configuration state — never a cardholder condition. | "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." |
scheme_credit_exhausted | The account that receives incoming scheme credits (Visa Direct Original Credit Transactions and similar) has reached its configured ceiling. | An operator-set risk limit, not a fault — raise the limit, or wait for the scheme's own settlement to retire outstanding exposure. The cardholder is being credited here, so nothing about their own balance is the cause. | "We couldn't complete this transfer right now. Please contact support." |
scheme_credit_currency_not_configured | The account that receives incoming scheme credits hasn't been set up for this transaction's currency. | A provisioning gap: only a tenant's first-ever scheme-credit currency is set up automatically, so a credit arriving in a different currency has nowhere configured to land. Not a cardholder condition — the destination card account itself was already verified. | "We couldn't complete this transfer right now. Please contact support." |
scheme_credit_posting_unconfirmed | An incoming scheme credit was submitted to the ledger and we could not confirm whether it posted. | An internal fault on our side. Retrying the identical message will not resolve it — the recorded decision is replayed — so it needs a reconciler, not a retry. | "We couldn't confirm this transfer. 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." |
incremental_original_unknown | An incremental authorization's original transaction couldn't be resolved. | The original never existed, is older than we retain authorization records for, or belongs to a different cardholder — these are deliberately indistinguishable on the wire, so a cross-tenant lookup can't be used to confirm a transaction exists. | "This purchase couldn't be processed. Please try again or contact support." |
incremental_original_not_amendable | An incremental authorization's original is real, but its increment couldn't be reserved against it. | Two distinct causes share this token, and they are not distinguishable from the response alone: (1) the original's hold has already moved on — captured, released or expired — so the stay or rental this incremental extends has settled or lapsed; or (2) a technical fault on our side left the increment's outcome uncertain while the original hold itself is still live. | "This purchase couldn't be extended. Please contact support before retrying — a new authorization may create a duplicate hold on this card." |
incremental_increment_withdrawn | An earlier attempt at this same incremental raised the hold and then correctly undid it, so there is nothing left to build on. | A technical fault on our side interrupted the first attempt; the cleanup succeeded, which is why we can say this precisely rather than falling back to incremental_original_not_amendable. Nothing is reserved and nothing is stranded — but this exact message can never succeed, because its increment carries a one-time identifier we have already used and undone. Unlike its neighbour above, a new authorization is safe here and is the remedy. | "This purchase couldn't be extended. Please try the extension again." |
incremental_amend_invalid | The ledger refused the increment amount itself (e.g. zero or negative). | An internal fault on our side, not a cardholder condition. | "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. |
stip_incremental_original_budget_exhausted | Stood in because resolving an incremental authorization's original transaction ran out of time. |
stip_replay_authority_unreadable | Stood in because we couldn't read whether this transaction had already been decided. |
stip_idempotency_store_unavailable | Stood in because we couldn't check whether this request was already being processed. |
stip_idempotency_key_malformed | Stood in because the request could not be matched to a valid account scope. |
stip_velocity_rules_unreadable | Stood in because we couldn't read the card's spending-limit rules, so we couldn't check them. Not a limit breach. |
stip_fraud_unavailable | Stood in because we couldn't read the programme's risk settings, so the transaction couldn't be scored. Not a risk verdict — see below. |
Almost 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."
One exception, and it matters if you retry automatically.
stip_idempotency_key_malformed is not transient: it means the request could
not be tied to an account scope at all, so an identical retry fails
identically, every time. Treat it as you would a 400 — the cardholder message
above is still the right thing to show, but retrying the same request will not
help, and a retry loop on it will never terminate. If you see it, the request
is malformed on our side or yours; tell us.
stip_fraud_unavailable is not a risk verdict, and the distinction is worth
holding on to. It means the programme's risk settings could not be read, so
the transaction was never scored at all — nobody judged this cardholder. It is
transient and the standard message above is the right one; in particular, do not
show the fraud copy from the Risk section, which would tell a cardholder
something about themselves that we did not conclude.
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.