Skip to main content
RapidDev - Software Development Agency
stripe-guide

How to handle declined payments with Stripe API

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.

What you'll learn

  • How to programmatically catch and categorize Stripe declines
  • How to build a decline recovery flow with user prompts
  • How to implement safe retry logic for transient failures
  • How to track decline analytics for revenue optimization
  • How to handle declines differently for one-time vs subscription payments
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate7 min read25 minutesStripe API v2024-12+, Node.js 18+, Express 4+March 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1const DECLINE_CATEGORIES = {
2 // Customer must update their card or contact bank
3 customer_action: [
4 'insufficient_funds', 'expired_card', 'incorrect_cvc',
5 'incorrect_number', 'card_not_supported',
6 ],
7 // Retry may succeed — transient failures
8 retryable: [
9 'processing_error', 'reenter_transaction',
10 'try_again_later',
11 ],
12 // Do not tell user the reason — fraud related
13 fraud: [
14 'fraudulent', 'lost_card', 'stolen_card',
15 'merchant_blacklist', 'pickup_card',
16 ],
17 // Bank-side blocks
18 bank_block: [
19 'do_not_honor', 'generic_decline',
20 'no_action_taken', 'restricted_card',
21 'transaction_not_allowed',
22 ],
23};
24
25function 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.

2

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.

typescript
1function getDeclineResponse(declineCode) {
2 const category = categorizeDecline(declineCode);
3
4 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.

3

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.

typescript
1async function createPaymentWithRetry(params, maxRetries = 2) {
2 let lastError;
3
4 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;
10
11 if (err.code !== 'card_declined') throw err;
12
13 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 }
21
22 // 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.

4

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.

typescript
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 };
10
11 // Store in your database
12 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.

5

Wire it all together in an Express endpoint

Combine the decline categorization, retry logic, user messaging, and analytics logging into a single payment endpoint.

typescript
1app.post('/api/pay', async (req, res) => {
2 const { amount, currency, paymentMethodId, customerId } = req.body;
3
4 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 });
12
13 if (!result.success) {
14 await logDecline(customerId, result.decline_code, amount, currency);
15 return res.status(402).json(result);
16 }
17
18 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

decline-handler.js
1require('dotenv').config();
2const express = require('express');
3const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
4
5const app = express();
6app.use(express.json());
7
8const 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};
14
15function categorizeDecline(code) {
16 for (const [cat, codes] of Object.entries(DECLINE_CATEGORIES)) {
17 if (codes.includes(code)) return cat;
18 }
19 return 'unknown';
20}
21
22function 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}
31
32function 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}
39
40async 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}
57
58app.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});
79
80const 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.

ChatGPT Prompt

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.

Stripe Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.