Shopify Decline Codes: The One State the API Cannot Give You
Book a 20 minute call Free, no commitment, and you leave with the answer either way.
The taxonomy already exists
Most advice on reading failed payments in Shopify starts by telling you to open each abandoned checkout, expand the payment event in the timeline, and record the country, the method and the decline message by hand. That is real work, and it is unnecessary.
The Admin GraphQL API carries a structured reason on the order transaction. It is called OrderTransactionErrorCode, it holds 47 values, and it is already close to the split you would have built by hand. This is a field to read, not a taxonomy to construct.
The grouping above is not in the schema. The schema gives you the values; the grouping is the operational decision you attach to them, and it is the part worth writing down once. A generic issuer refusal is not proof that a card is dead. An expired card is not something a retry will ever fix. Those two need opposite responses and they are separate values, so nothing has to be inferred from a message string.
Soft and hard is the wrong axis
The familiar split is soft decline against hard decline. It is a poor fit here because it describes the issuer's intent rather than your next move, and your next move is the only thing you can act on.
Group by next action instead. A charge that came back DO_NOT_HONOR wants one customer-initiated retry, or a different payment method, or a phone call to the bank. A charge that came back EXPIRED_CARD wants none of those; it wants the cardholder to update the card, and every automatic retry against it is a wasted attempt that teaches you nothing. CARD_TESTING wants no retry at all and probably wants somebody to look at the account.
The same discipline applies to authentication. AUTHENTICATION_FAILED and AUTHENTICATION_REQUIRED are their own values, distinct from any issuer decline, so a 3D Secure failure is already separable without guesswork.
The state that cannot exist
Now the part that is not a reporting gap.
There is no value in that enum for a shopper who begins the 3D Secure challenge and leaves. There cannot be one. No transaction is created, so there is no row to carry an error code, and nothing downstream can emit what was never recorded. That state is missing from the data model rather than from the reporting, which is exactly why it does not turn up however hard you look for it in the admin.
On Shopify Payments there is no separate gateway portal to recover it from either. For that setup the number does not exist anywhere, rather than existing somewhere inconvenient. It is worth being precise about which of those two you are dealing with before somebody spends a week looking.
The practical consequence is that your authorization rate and your decline breakdown will never reconcile to the same population. Some shoppers left in the middle of a challenge and produced no event of any kind. If you treat those as abandonment they disappear into a cart metric. If you treat them as declines you are counting something you never observed.
Why this makes local payment methods hard to prove
Cross-border European stores are often better served by offering the methods buyers already expect. A Dutch customer reaching for iDEAL, a Belgian for Bancontact, a Polish buyer for BLIK. Every order placed through a local method is a 3D Secure challenge that never had the chance to be abandoned.
The improvement is real. The measurement that would demonstrate it is the one that cannot be produced.
So it does not arrive as fewer authentication failures. It arrives as authorization rate rising with no matching fall in any decline code, and anybody watching the decline breakdown will see a gain appear from nowhere and attribute it to the wrong change. That is worth knowing before it happens rather than after, because the alternative is crediting a checkout redesign, a pricing change or a season for something a payment method did.
What to do with this
Read the error code from the order transaction rather than from the admin screen, and store it next to the order. It costs one field.
Write the next action beside each value once, as a table, and keep that table in version control rather than in a model. A versioned rule can be shown to somebody who asks why a customer was charged twice. A confidence score cannot.
Then keep authentication drop-off in its own row, explicitly labelled as unobserved, and never average it into either the decline rate or the abandonment rate. A number you cannot measure is not a zero, and recording it as one is how a gap becomes a wrong conclusion.
One caveat worth carrying. These values are normalised by Shopify across gateways rather than passed through from the processor, so how faithfully a third-party gateway's declines land in them is worth confirming against your own orders before you build a process on top of the split. If most of yours arrive as GENERIC_ERROR, that is a finding too, and better learned early.
Want this set up for your business?
Book a 20 minute call →