# How to handle declined payments with Stripe API

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 25 minutes
- Compatibility: Stripe API v2024-12+, Node.js 18+, Express 4+
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
const DECLINE_CATEGORIES = {
  // Customer must update their card or contact bank
  customer_action: [
    'insufficient_funds', 'expired_card', 'incorrect_cvc',
    'incorrect_number', 'card_not_supported',
  ],
  // Retry may succeed — transient failures
  retryable: [
    'processing_error', 'reenter_transaction',
    'try_again_later',
  ],
  // Do not tell user the reason — fraud related
  fraud: [
    'fraudulent', 'lost_card', 'stolen_card',
    'merchant_blacklist', 'pickup_card',
  ],
  // Bank-side blocks
  bank_block: [
    'do_not_honor', 'generic_decline',
    'no_action_taken', 'restricted_card',
    'transaction_not_allowed',
  ],
};

function categorizeDecline(declineCode) {
  for (const [category, codes] of Object.entries(DECLINE_CATEGORIES)) {
    if (codes.includes(declineCode)) return category;
  }
  return 'unknown';
}
```

**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.

```
function getDeclineResponse(declineCode) {
  const category = categorizeDecline(declineCode);

  switch (category) {
    case 'customer_action':
      return {
        message: getCustomerMessage(declineCode),
        action: 'update_payment_method',
        retry: false,
      };
    case 'retryable':
      return {
        message: 'A temporary issue occurred. Retrying your payment...',
        action: 'auto_retry',
        retry: true,
      };
    case 'fraud':
      return {
        message: 'Your card was declined. Please contact your bank or try a different card.',
        action: 'contact_bank',
        retry: false,
      };
    case 'bank_block':
      return {
        message: 'Your bank declined this transaction. Please contact them or try a different card.',
        action: 'contact_bank',
        retry: false,
      };
    default:
      return {
        message: 'Payment could not be processed. Please try a different payment method.',
        action: 'try_different_method',
        retry: false,
      };
  }
}
```

**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.

```
async function createPaymentWithRetry(params, maxRetries = 2) {
  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const paymentIntent = await stripe.paymentIntents.create(params);
      return { success: true, paymentIntent };
    } catch (err) {
      lastError = err;

      if (err.code !== 'card_declined') throw err;

      const category = categorizeDecline(err.decline_code);
      if (category !== 'retryable' || attempt === maxRetries) {
        return {
          success: false,
          ...getDeclineResponse(err.decline_code),
          decline_code: err.decline_code,
        };
      }

      // Wait before retry (exponential backoff)
      await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
    }
  }
}
```

**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.

```
async function logDecline(customerId, declineCode, amount, currency) {
  const entry = {
    timestamp: new Date().toISOString(),
    customer_id: customerId,
    decline_code: declineCode,
    category: categorizeDecline(declineCode),
    amount,
    currency,
  };

  // Store in your database
  console.log('Decline logged:', JSON.stringify(entry));
  // await db.collection('payment_declines').insertOne(entry);
}
```

**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.

```
app.post('/api/pay', async (req, res) => {
  const { amount, currency, paymentMethodId, customerId } = req.body;

  const result = await createPaymentWithRetry({
    amount,
    currency: currency || 'usd',
    customer: customerId,
    payment_method: paymentMethodId,
    confirm: true,
    automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
  });

  if (!result.success) {
    await logDecline(customerId, result.decline_code, amount, currency);
    return res.status(402).json(result);
  }

  res.json({ success: true, id: result.paymentIntent.id });
});
```

**Expected result:** The endpoint handles payments with automatic retry, user-friendly decline messages, and analytics logging.

## Complete code example

File: `decline-handler.js`

```javascript
require('dotenv').config();
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();
app.use(express.json());

const DECLINE_CATEGORIES = {
  customer_action: ['insufficient_funds', 'expired_card', 'incorrect_cvc', 'incorrect_number'],
  retryable: ['processing_error', 'reenter_transaction', 'try_again_later'],
  fraud: ['fraudulent', 'lost_card', 'stolen_card', 'merchant_blacklist'],
  bank_block: ['do_not_honor', 'generic_decline', 'restricted_card', 'transaction_not_allowed'],
};

function categorizeDecline(code) {
  for (const [cat, codes] of Object.entries(DECLINE_CATEGORIES)) {
    if (codes.includes(code)) return cat;
  }
  return 'unknown';
}

function getDeclineMessage(code) {
  const msgs = {
    insufficient_funds: 'Insufficient funds. Please try a different card.',
    expired_card: 'Card expired. Please update your payment method.',
    incorrect_cvc: 'Incorrect CVC. Please re-enter.',
    incorrect_number: 'Incorrect card number. Please check and retry.',
  };
  return msgs[code] || 'Payment declined. Please try a different method or contact your bank.';
}

function getDeclineResponse(code) {
  const cat = categorizeDecline(code);
  if (cat === 'customer_action') return { message: getDeclineMessage(code), action: 'update_method', retry: false };
  if (cat === 'retryable') return { message: 'Temporary issue. Retrying...', action: 'auto_retry', retry: true };
  if (cat === 'fraud') return { message: 'Card declined. Contact your bank.', action: 'contact_bank', retry: false };
  return { message: 'Card declined. Try a different card or contact your bank.', action: 'contact_bank', retry: false };
}

async function payWithRetry(params, maxRetries = 2) {
  let lastErr;
  for (let i = 0; i <= maxRetries; i++) {
    try {
      const pi = await stripe.paymentIntents.create(params);
      return { success: true, paymentIntent: pi };
    } catch (err) {
      lastErr = err;
      if (err.code !== 'card_declined') throw err;
      const cat = categorizeDecline(err.decline_code);
      if (cat !== 'retryable' || i === maxRetries) {
        return { success: false, ...getDeclineResponse(err.decline_code), decline_code: err.decline_code };
      }
      await new Promise(r => setTimeout(r, 1000 * (i + 1)));
    }
  }
}

app.post('/api/pay', async (req, res) => {
  const { amount, currency, paymentMethodId, customerId } = req.body;
  try {
    const result = await payWithRetry({
      amount,
      currency: currency || 'usd',
      customer: customerId,
      payment_method: paymentMethodId,
      confirm: true,
      automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
    });
    if (!result.success) {
      console.log('Decline:', { customer: customerId, code: result.decline_code });
      return res.status(402).json(result);
    }
    res.json({ success: true, id: result.paymentIntent.id });
  } catch (err) {
    console.error('Payment error:', err.message);
    res.status(500).json({ error: 'Payment processing failed' });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
```

## Common mistakes

- **Retrying all decline types including fraud and insufficient funds** — undefined Fix: Only retry transient errors like processing_error. Retrying card_declined with insufficient_funds will fail every time until the customer adds funds.
- **Not logging decline codes for analytics** — undefined Fix: Log every decline with its code, amount, and customer. High decline rates on specific codes may indicate integration issues or fraud patterns.
- **Treating all declines the same with a generic error message** — undefined Fix: Categorize declines and give specific guidance: 'card expired' → update card, 'insufficient funds' → try different card, fraud codes → generic bank message.
- **Retrying too many times or too quickly** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-handle-declined-payments-with-stripe-api
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-handle-declined-payments-with-stripe-api
