Handle declined payments programmatically by catching StripeCardError exceptions, reading the decline_code, mapping codes to user actions, and implementing retry flows. Categorize declines into retryable (processing_error, rate_limit) and non-retryable (insufficient_funds, lost_card). Log declines for analytics, prompt users to update payment methods, and never expose fraud-related decline reasons.
Building a Robust Decline Handling System
Card declines happen to 5-10% of all online transactions. How you handle them directly impacts your conversion rate and revenue. A well-designed decline handling system categorizes failures, guides users to resolution, retries transient errors, and logs everything for analytics. This goes beyond displaying error messages — it's about building a programmatic recovery pipeline that maximizes successful payments.
Prerequisites
- A Stripe account in test mode with test products created
- Node.js 18+ with Express and the Stripe npm package
- A frontend payment form using Stripe Elements
- Basic understanding of async/await error handling
Step-by-step guide
Categorize decline codes into actionable groups
Categorize decline codes into actionable groups
Group Stripe decline codes by what action the customer or system should take. This drives your error handling logic — some declines need customer action, some can be retried, and some require no action.
1const DECLINE_CATEGORIES = {2 // Customer must update their card or contact bank3 customer_action: [4 'insufficient_funds', 'expired_card', 'incorrect_cvc',5 'incorrect_number', 'card_not_supported',6 ],7 // Retry may succeed — transient failures8 retryable: [9 'processing_error', 'reenter_transaction',10 'try_again_later',11 ],12 // Do not tell user the reason — fraud related13 fraud: [14 'fraudulent', 'lost_card', 'stolen_card',15 'merchant_blacklist', 'pickup_card',16 ],17 // Bank-side blocks18 bank_block: [19 'do_not_honor', 'generic_decline',20 'no_action_taken', 'restricted_card',21 'transaction_not_allowed',22 ],23};2425function categorizeDecline(declineCode) {26 for (const [category, codes] of Object.entries(DECLINE_CATEGORIES)) {27 if (codes.includes(declineCode)) return category;28 }29 return 'unknown';30}Expected result: Every decline code is categorized into an action group: customer_action, retryable, fraud, or bank_block.
Build the decline response handler
Build the decline response handler
Return different responses based on the decline category. Include actionable guidance for the customer, a retry flag for your frontend, and log details for your team.
1function getDeclineResponse(declineCode) {2 const category = categorizeDecline(declineCode);34 switch (category) {5 case 'customer_action':6 return {7 message: getCustomerMessage(declineCode),8 action: 'update_payment_method',9 retry: false,10 };11 case 'retryable':12 return {13 message: 'A temporary issue occurred. Retrying your payment...',14 action: 'auto_retry',15 retry: true,16 };17 case 'fraud':18 return {19 message: 'Your card was declined. Please contact your bank or try a different card.',20 action: 'contact_bank',21 retry: false,22 };23 case 'bank_block':24 return {25 message: 'Your bank declined this transaction. Please contact them or try a different card.',26 action: 'contact_bank',27 retry: false,28 };29 default:30 return {31 message: 'Payment could not be processed. Please try a different payment method.',32 action: 'try_different_method',33 retry: false,34 };35 }36}Expected result: Each decline produces a structured response with a user message, recommended action, and retry flag.
Implement automatic retry for transient failures
Implement automatic retry for transient failures
For retryable declines, wait briefly and try again. Limit retries to avoid hammering the API. Only retry automatically for transient errors, never for card issues.
1async function createPaymentWithRetry(params, maxRetries = 2) {2 let lastError;34 for (let attempt = 0; attempt <= maxRetries; attempt++) {5 try {6 const paymentIntent = await stripe.paymentIntents.create(params);7 return { success: true, paymentIntent };8 } catch (err) {9 lastError = err;1011 if (err.code !== 'card_declined') throw err;1213 const category = categorizeDecline(err.decline_code);14 if (category !== 'retryable' || attempt === maxRetries) {15 return {16 success: false,17 ...getDeclineResponse(err.decline_code),18 decline_code: err.decline_code,19 };20 }2122 // Wait before retry (exponential backoff)23 await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));24 }25 }26}Expected result: Transient failures are retried automatically with exponential backoff. Non-retryable declines return immediately with an appropriate response.
Log declines for analytics
Log declines for analytics
Track every decline with its code, category, customer, and timestamp. This data helps you identify patterns — high decline rates may indicate integration issues, fraud attacks, or pricing problems.
1async function logDecline(customerId, declineCode, amount, currency) {2 const entry = {3 timestamp: new Date().toISOString(),4 customer_id: customerId,5 decline_code: declineCode,6 category: categorizeDecline(declineCode),7 amount,8 currency,9 };1011 // Store in your database12 console.log('Decline logged:', JSON.stringify(entry));13 // await db.collection('payment_declines').insertOne(entry);14}Expected result: Every decline is logged with structured data for later analysis and monitoring.
Wire it all together in an Express endpoint
Wire it all together in an Express endpoint
Combine the decline categorization, retry logic, user messaging, and analytics logging into a single payment endpoint.
1app.post('/api/pay', async (req, res) => {2 const { amount, currency, paymentMethodId, customerId } = req.body;34 const result = await createPaymentWithRetry({5 amount,6 currency: currency || 'usd',7 customer: customerId,8 payment_method: paymentMethodId,9 confirm: true,10 automatic_payment_methods: { enabled: true, allow_redirects: 'never' },11 });1213 if (!result.success) {14 await logDecline(customerId, result.decline_code, amount, currency);15 return res.status(402).json(result);16 }1718 res.json({ success: true, id: result.paymentIntent.id });19});Expected result: The endpoint handles payments with automatic retry, user-friendly decline messages, and analytics logging.
Complete working example
1require('dotenv').config();2const express = require('express');3const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);45const app = express();6app.use(express.json());78const DECLINE_CATEGORIES = {9 customer_action: ['insufficient_funds', 'expired_card', 'incorrect_cvc', 'incorrect_number'],10 retryable: ['processing_error', 'reenter_transaction', 'try_again_later'],11 fraud: ['fraudulent', 'lost_card', 'stolen_card', 'merchant_blacklist'],12 bank_block: ['do_not_honor', 'generic_decline', 'restricted_card', 'transaction_not_allowed'],13};1415function categorizeDecline(code) {16 for (const [cat, codes] of Object.entries(DECLINE_CATEGORIES)) {17 if (codes.includes(code)) return cat;18 }19 return 'unknown';20}2122function getDeclineMessage(code) {23 const msgs = {24 insufficient_funds: 'Insufficient funds. Please try a different card.',25 expired_card: 'Card expired. Please update your payment method.',26 incorrect_cvc: 'Incorrect CVC. Please re-enter.',27 incorrect_number: 'Incorrect card number. Please check and retry.',28 };29 return msgs[code] || 'Payment declined. Please try a different method or contact your bank.';30}3132function getDeclineResponse(code) {33 const cat = categorizeDecline(code);34 if (cat === 'customer_action') return { message: getDeclineMessage(code), action: 'update_method', retry: false };35 if (cat === 'retryable') return { message: 'Temporary issue. Retrying...', action: 'auto_retry', retry: true };36 if (cat === 'fraud') return { message: 'Card declined. Contact your bank.', action: 'contact_bank', retry: false };37 return { message: 'Card declined. Try a different card or contact your bank.', action: 'contact_bank', retry: false };38}3940async function payWithRetry(params, maxRetries = 2) {41 let lastErr;42 for (let i = 0; i <= maxRetries; i++) {43 try {44 const pi = await stripe.paymentIntents.create(params);45 return { success: true, paymentIntent: pi };46 } catch (err) {47 lastErr = err;48 if (err.code !== 'card_declined') throw err;49 const cat = categorizeDecline(err.decline_code);50 if (cat !== 'retryable' || i === maxRetries) {51 return { success: false, ...getDeclineResponse(err.decline_code), decline_code: err.decline_code };52 }53 await new Promise(r => setTimeout(r, 1000 * (i + 1)));54 }55 }56}5758app.post('/api/pay', async (req, res) => {59 const { amount, currency, paymentMethodId, customerId } = req.body;60 try {61 const result = await payWithRetry({62 amount,63 currency: currency || 'usd',64 customer: customerId,65 payment_method: paymentMethodId,66 confirm: true,67 automatic_payment_methods: { enabled: true, allow_redirects: 'never' },68 });69 if (!result.success) {70 console.log('Decline:', { customer: customerId, code: result.decline_code });71 return res.status(402).json(result);72 }73 res.json({ success: true, id: result.paymentIntent.id });74 } catch (err) {75 console.error('Payment error:', err.message);76 res.status(500).json({ error: 'Payment processing failed' });77 }78});7980const PORT = process.env.PORT || 3000;81app.listen(PORT, () => console.log(`Server on port ${PORT}`));Common mistakes when handling declined payments with Stripe API
Why it's a problem: Retrying all decline types including fraud and insufficient funds
How to avoid: Only retry transient errors like processing_error. Retrying card_declined with insufficient_funds will fail every time until the customer adds funds.
Why it's a problem: Not logging decline codes for analytics
How to avoid: Log every decline with its code, amount, and customer. High decline rates on specific codes may indicate integration issues or fraud patterns.
Why it's a problem: Treating all declines the same with a generic error message
How to avoid: Categorize declines and give specific guidance: 'card expired' → update card, 'insufficient funds' → try different card, fraud codes → generic bank message.
Why it's a problem: Retrying too many times or too quickly
How to avoid: Limit automatic retries to 2-3 attempts with exponential backoff. Excessive retries can trigger Stripe rate limits and frustrate users.
Best practices
- Categorize decline codes by required action: customer fix, auto-retry, or contact bank
- Only auto-retry transient failures like processing_error — never retry card issues automatically
- Use exponential backoff for retries: 1s, 2s, 4s
- Log all declines with structured data for analytics and monitoring
- Show specific, actionable messages based on the decline category
- Never expose fraud-related decline details to the customer
- Provide an easy way for customers to update their payment method after a decline
- Monitor decline rates by code to catch integration issues early
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Node.js Stripe payment handler that categorizes card decline codes into groups (customer_action, retryable, fraud, bank_block), implements automatic retry with exponential backoff for transient failures, and returns different user messages based on the decline category. Include analytics logging.
Build a complete Stripe decline handling system in Node.js Express. Categorize decline codes, implement retry logic for transient failures only, return actionable user messages per category, hide fraud details, and log declines for analytics.
Frequently asked questions
What percentage of online payments get declined?
Typically 5-10% of online card payments are declined. The rate varies by industry, geography, and average transaction size. Proper decline handling can recover a significant portion of these.
Should I retry a declined payment automatically?
Only for transient errors like processing_error or try_again_later. Do not auto-retry declines like insufficient_funds, expired_card, or fraud-related codes — these require customer action.
How many times should I retry a transient decline?
2-3 retries with exponential backoff (1s, 2s, 4s) is standard. More retries rarely help and can trigger Stripe rate limits.
How do I handle declines differently for subscriptions vs one-time payments?
For subscriptions, use Stripe Smart Retries (automatic ML-optimized retries) and dunning emails. For one-time payments, prompt the user immediately to try again or use a different card.
What is the best way to track decline patterns?
Log every decline with the code, amount, customer, and timestamp. Build a dashboard showing decline rates by code over time. A sudden spike in a specific code often indicates an integration issue or fraud attack.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation