A declined card is a sale you can still save, if the buyer knows what to do next. Most checkouts show "Your card was declined" for every case, so a typo in the CVC looks the same as a stolen card.
Stripe tells you the reason in decline_code. You get it on the card error (err.decline_code when err.type === 'card_error') and on a PaymentIntent as last_payment_error.decline_code. Stripe lists 50 codes. They fall into five buckets, and each bucket needs a different message.
Codes checked against Stripe's decline codes docs on 18 October 2026.
The five buckets
| Bucket | Codes | What to do |
|---|---|---|
| Buyer can fix it | 16 | Ask them to correct the number, CVC, date, address or PIN, or finish 3D Secure. |
| Retry | 5 | A temporary error at the bank or network. Retry once. |
| Another card | 6 | No funds, a limit, or the card can't pay this. Ask for another card. |
| Bank must say why | 19 | The issuer gave no reason. Only the buyer's bank can explain. |
| Show as generic | 4 | Fraud, lost, stolen or block list. Never tell the buyer the real reason. |
The last row matters most. Stripe's guidance for fraudulent, lost_card, stolen_card and merchant_blacklist is to show a generic decline, so you never tell a fraudster which check caught them.
Drop-in mapping
const BUCKET = { fix: ['authentication_required', 'authentication_not_handled', 'duplicate_transaction', 'incorrect_address', 'incorrect_cvc', 'incorrect_number', 'incorrect_pin', 'incorrect_zip', 'invalid_cvc', 'invalid_expiry_month', 'invalid_expiry_year', 'invalid_number', 'invalid_pin', 'offline_pin_required', 'online_or_offline_pin_required', 'mobile_device_authentication_required'], retry: ['approve_with_id', 'issuer_not_available', 'processing_error', 'reenter_transaction', 'try_again_later'], otherCard: ['card_not_supported', 'expired_card', 'insufficient_funds', 'pin_try_exceeded', 'testmode_decline', 'withdrawal_count_limit_exceeded'], generic: ['fraudulent', 'lost_card', 'merchant_blacklist', 'stolen_card'], // everything else: the bank must say why }; const MESSAGE = { fix: 'Some card details don\'t match. Please check them and try again.', retry: 'We could not approve this payment. Please try again in a moment.', otherCard: 'This card can\'t be used for this payment. Please use another card.', bank: 'Your bank declined this payment. Please contact your bank or use another card.', }; export function declineMessage(code) { if (BUCKET.generic.includes(code)) return MESSAGE.bank; // never reveal fraud checks for (const k of ['fix', 'retry', 'otherCard']) if (BUCKET[k].includes(code)) return MESSAGE[k]; return MESSAGE.bank; } Two refinements worth adding:
- For
duplicate_transaction, tell the buyer to check their email first. The earlier payment may already have gone through. - For
authentication_requiredon an off-session renewal, you can't show 3D Secure in the background. Email the customer a link to come back and confirm.
Test them before buyers do
Many codes have a Stripe test card. A few:
-
insufficient_funds: 4000 0000 0000 9995 -
expired_card: 4000 0000 0000 0069 -
incorrect_cvc: 4000 0000 0000 0127 -
generic_decline: 4000 0000 0000 0002
The full list
All 50 codes, each with its meaning, what to do, a buyer message you can copy and its test card where one exists, are on one searchable page: Stripe decline codes 2026. The test cards for every scenario are at Stripe test cards.
Disclosure: I build AgentaOS, a merchant of record. The pages above are free and need no account.

