/stripe-guides

How to handle declined payments with Stripe API?

Learn how to handle declined payments with Stripe API, including error handling, retry logic, user messaging, and best practices for managing payment failures.

Matt Graham, CEO of Rapid Developers

Book a call with an Expert

Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.

Book a free consultation

How to handle declined payments with Stripe API?

How to Handle Declined Payments with Stripe API: A Comprehensive Tutorial

 

Step 1: Understanding Stripe Payment Declines

 

When payments are declined in Stripe, it's important to understand the different types of declines:

  • Card declines (insufficient funds, expired cards, etc.)
  • Authentication failures (3D Secure challenges)
  • Risk-based declines (fraud detection)
  • Processing errors (network issues)

Each decline comes with a specific error code and message that helps identify the reason for the failure.

 

Step 2: Setting Up Your Stripe Project

 

First, install the Stripe library for your programming language. For Node.js:

npm install stripe

Initialize the Stripe client with your secret key:

const stripe = require('stripe')('sk_test_YOUR_SECRET_KEY');

// For async/await usage
async function initStripe() {
  const stripe = require('stripe')('sk_test_YOUR_SECRET_KEY');
  return stripe;
}

 

Step 3: Implementing Basic Payment Processing

 

Let's implement a basic payment endpoint that handles potential declines:

async function createPayment(req, res) {
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: 2000, // Amount in cents
      currency: 'usd',
      payment\_method: req.body.paymentMethodId,
      confirm: true, // Confirm the payment immediately
      return\_url: 'https://yourwebsite.com/payment-complete', // For 3D Secure redirects
    });
    
    // Handle successful payment
    return res.json({ success: true, paymentIntent });
  } catch (error) {
    // Handle declined payment
    return handlePaymentError(error, res);
  }
}

 

Step 4: Creating a Robust Error Handler

 

Implement a comprehensive error handler for payment declines:

function handlePaymentError(error, res) {
  // Extract key information from the error
  const errorType = error.type;
  const errorCode = error.code || (error.decline\_code ? `decline_${error.decline_code}` : null);
  const errorMessage = error.message;
  
  console.log('Payment error:', {
    type: errorType,
    code: errorCode,
    message: errorMessage,
    raw: error
  });
  
  // Categorize and handle specific error types
  switch (errorType) {
    case 'StripeCardError':
      // Card was declined
      return handleCardDecline(error, res);
    
    case 'StripeInvalidRequestError':
      // Invalid parameters were supplied to Stripe's API
      return res.status(400).json({
        success: false,
        error: 'invalid\_request',
        message: errorMessage
      });
    
    case 'StripeAuthenticationError':
      // Authentication with Stripe's API failed
      console.error('Stripe API key error:', error);
      return res.status(500).json({
        success: false,
        error: 'authentication\_error',
        message: 'Internal payment configuration error'
      });
    
    case 'StripeAPIError':
      // Stripe API error - retry logic may be appropriate
      return res.status(503).json({
        success: false,
        error: 'api\_error',
        message: 'Payment service temporarily unavailable',
        shouldRetry: true
      });
    
    default:
      // Unexpected error
      console.error('Unexpected payment error:', error);
      return res.status(500).json({
        success: false,
        error: 'unknown\_error',
        message: 'An unexpected error occurred'
      });
  }
}

 

Step 5: Handling Card Declines Specifically

 

Card declines require special handling to provide appropriate feedback:

function handleCardDecline(error, res) {
  const declineCode = error.decline_code || 'unknown_decline';
  const errorCode = error.code;
  
  // Customize response based on decline code
  switch (declineCode) {
    case 'insufficient\_funds':
      return res.status(400).json({
        success: false,
        error: 'card\_declined',
        decline\_code: declineCode,
        message: 'The card has insufficient funds to complete the purchase',
        user_action: 'try_different\_card'
      });
    
    case 'lost\_card':
    case 'stolen\_card':
      return res.status(400).json({
        success: false,
        error: 'card\_declined',
        decline\_code: declineCode,
        message: 'This card has been reported lost or stolen',
        user_action: 'try_different\_card'
      });
    
    case 'expired\_card':
      return res.status(400).json({
        success: false,
        error: 'card\_declined',
        decline\_code: declineCode,
        message: 'The card has expired',
        user_action: 'update_card\_details'
      });
    
    default:
      // Generic decline or unknown reason
      return res.status(400).json({
        success: false,
        error: 'card\_declined',
        decline\_code: declineCode,
        error\_code: errorCode,
        message: error.message || 'The card was declined',
        user_action: 'try_different_card_or_contact_bank'
      });
  }
}

 

Step 6: Handling Authentication Required Errors (3D Secure)

 

For cards that require 3D Secure authentication:

async function handlePaymentWithAuth(paymentIntentId, res) {
  try {
    const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
    
    if (paymentIntent.status === 'requires\_action' && 
        paymentIntent.next\_action && 
        paymentIntent.next_action.type === 'use_stripe\_sdk') {
      
      // Tell the client to handle the action
      return res.json({
        success: false,
        requires\_action: true,
        payment_intent_client_secret: paymentIntent.client_secret,
        next_action: paymentIntent.next_action
      });
    } else if (paymentIntent.status === 'succeeded') {
      // Payment succeeded
      return res.json({ success: true, paymentIntent });
    } else {
      // Other status
      return res.json({
        success: false,
        requires\_action: false,
        payment\_intent: paymentIntent,
        message: `Payment status: ${paymentIntent.status}`
      });
    }
  } catch (error) {
    return handlePaymentError(error, res);
  }
}

 

Step 7: Implementing Client-Side Error Handling

 

Create a robust client-side error handler to interpret Stripe API responses:

// Client-side JavaScript
async function processPayment(paymentMethodId) {
  try {
    // Send payment information to your server
    const response = await fetch('/api/create-payment', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ paymentMethodId })
    });
    
    const result = await response.json();
    
    if (result.success) {
      // Handle successful payment
      showSuccessMessage(result.paymentIntent);
      return;
    }
    
    // Handle different error scenarios
    if (result.requires\_action) {
      // Handle 3D Secure authentication
      const { error, paymentIntent } = await stripe.handleCardAction(
        result.payment_intent_client\_secret
      );
      
      if (error) {
        // Show error to your customer
        showError(error.message);
      } else {
        // The card action has been handled
        // The PaymentIntent can be confirmed again on the server
        const confirmResult = await fetch('/api/confirm-payment', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ paymentIntentId: paymentIntent.id })
        });
        
        const serverResponse = await confirmResult.json();
        
        if (serverResponse.success) {
          showSuccessMessage(serverResponse.paymentIntent);
        } else {
          showError(serverResponse.message);
        }
      }
    } else {
      // Show decline message to customer based on error type
      handleDeclineError(result);
    }
  } catch (error) {
    console.error('Payment processing error:', error);
    showError('An unexpected error occurred. Please try again.');
  }
}

 

Step 8: Creating User-Friendly Error Messages

 

Display appropriate error messages to users based on decline types:

function handleDeclineError(result) {
  const errorContainer = document.getElementById('payment-errors');
  let errorMessage = result.message || 'Your payment was declined.';
  let userAction = '';
  
  // Create actionable message based on decline code
  switch (result.decline\_code) {
    case 'insufficient\_funds':
      userAction = 'Please use a different card or add funds to your account.';
      break;
    
    case 'lost\_card':
    case 'stolen\_card':
      userAction = 'This card has been reported lost or stolen. Please use a different payment method.';
      break;
    
    case 'expired\_card':
      userAction = 'This card has expired. Please update your card information or use a different card.';
      break;
    
    case 'incorrect\_cvc':
      userAction = 'The security code you entered is incorrect. Please check and try again.';
      break;
    
    case 'processing\_error':
      userAction = 'An error occurred while processing your card. Please try again in a moment.';
      break;
    
    default:
      if (result.error === 'authentication\_error') {
        userAction = 'Payment authentication failed. Please try a different payment method or contact your bank.';
      } else {
        userAction = 'Please try a different payment method or contact your bank for more information.';
      }
  }
  
  errorContainer.innerHTML = \`
    

Payment Failed: ${errorMessage}

${userAction}

\`; errorContainer.style.display = 'block'; // Log error for analytics logPaymentError(result); }

 

Step 9: Implementing Automatic Retry Logic

 

For certain types of declines, implement automatic retry logic:

async function createPaymentWithRetry(paymentData, maxRetries = 2) {
  let attempts = 0;
  
  while (attempts < maxRetries) {
    try {
      attempts++;
      
      const paymentIntent = await stripe.paymentIntents.create({
        amount: paymentData.amount,
        currency: paymentData.currency,
        payment\_method: paymentData.paymentMethodId,
        confirm: true,
        return\_url: paymentData.returnUrl
      });
      
      return { success: true, paymentIntent };
    } catch (error) {
      // Only retry on certain errors
      if (attempts >= maxRetries || !isRetryableError(error)) {
        return { success: false, error };
      }
      
      // Wait before retrying (exponential backoff)
      const backoffMs = Math.pow(2, attempts) \* 500;
      await new Promise(resolve => setTimeout(resolve, backoffMs));
    }
  }
}

function isRetryableError(error) {
  // Network errors and processing errors are retryable
  if (error.type === 'StripeAPIError') {
    return true;
  }
  
  // Card errors like insufficient funds are not retryable without user intervention
  if (error.type === 'StripeCardError') {
    return false;
  }
  
  // Some idempotency errors can be retried
  if (error.type === 'StripeIdempotencyError') {
    return true;
  }
  
  return false;
}

 

Step 10: Tracking Payment Decline Metrics

 

Implement analytics tracking for payment declines to identify patterns:

function logPaymentError(error) {
  // Collect relevant details for analytics
  const errorDetails = {
    timestamp: new Date().toISOString(),
    errorType: error.error || 'unknown',
    declineCode: error.decline\_code || null,
    errorCode: error.error\_code || null,
    message: error.message || 'Unknown error',
    // Don't log sensitive data like card numbers
    paymentMethod: error.payment_method_type || 'card',
    currency: error.currency || null,
    isProduction: process.env.NODE\_ENV === 'production'
  };
  
  // Log to your analytics or monitoring system
  console.log('Payment error logged:', errorDetails);
  
  // Example: send to analytics service
  if (typeof analyticsService !== 'undefined') {
    analyticsService.track('payment\_error', errorDetails);
  }
  
  // Store in database for later analysis
  storePaymentError(errorDetails);
}

 

Step 11: Implementing Webhooks to Catch Asynchronous Payment Events

 

Set up Stripe webhooks to handle asynchronous payment status changes:

// Express.js example
const express = require('express');
const app = express();

app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const endpointSecret = 'whsec_your_webhook_signing_secret';
  
  let event;
  
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  
  // Handle the event
  switch (event.type) {
    case 'payment\_intent.succeeded':
      const paymentIntentSucceeded = event.data.object;
      await handleSuccessfulPayment(paymentIntentSucceeded);
      break;
      
    case 'payment_intent.payment_failed':
      const paymentIntentFailed = event.data.object;
      await handleFailedPayment(paymentIntentFailed);
      break;
      
    default:
      // Unexpected event type
      console.log(`Unhandled event type ${event.type}`);
  }
  
  // Return a 200 response to acknowledge receipt of the event
  res.send();
});

// Handle failed payments from webhook events
async function handleFailedPayment(paymentIntent) {
  // Extract decline information
  const chargesData = paymentIntent.charges.data;
  let declineCode = null;
  let failureMessage = null;
  
  if (chargesData && chargesData.length > 0) {
    const latestCharge = chargesData[0];
    declineCode = latestCharge.failure\_code;
    failureMessage = latestCharge.failure\_message;
  }
  
  console.log(`Payment failed: ${paymentIntent.id}, Reason: ${declineCode} - ${failureMessage}`);
  
  // Store the failure in database
  await storePaymentFailure(paymentIntent.id, declineCode, failureMessage);
  
  // Notify customer about the decline if needed
  if (paymentIntent.receipt\_email) {
    await sendDeclineNotification(
      paymentIntent.receipt\_email,
      declineCode,
      failureMessage,
      paymentIntent.id
    );
  }
}

 

Step 12: Setting Up Dunning Management for Subscriptions

 

For subscription billing, implement dunning management to retry failed payments:

// Configure subscription with smart retry settings
async function createSubscriptionWithSmartRetry(customerId, priceId) {
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment\_settings: {
      payment_method_types: ['card'],
      save_default_payment_method: 'on_subscription'
    },
    collection_method: 'charge_automatically',
    // Specify automatic tax if needed
    automatic\_tax: { enabled: true },
    // Configure payment retry settings
    payment_behavior: 'default_incomplete',
    // Define the number of days before canceling subscription on failed payment
    cancel_at_period\_end: false
  });
  
  return subscription;
}

// Update an existing subscription's invoice settings for better retry handling
async function updateSubscriptionRetrySettings(subscriptionId) {
  const subscription = await stripe.subscriptions.update(subscriptionId, {
    // Create prorations when the billing cycle changes
    proration_behavior: 'create_prorations',
    // Configure billing cycle anchor if needed
    billing_cycle_anchor: 'unchanged'
  });
  
  // Update the customer's invoice settings for this subscription
  const customer = await stripe.customers.update(subscription.customer, {
    invoice\_settings: {
      // Number of days to wait before attempting to finalize the next invoice
      default_payment_method: subscription.default_payment_method
    }
  });
  
  return { subscription, customer };
}

 

Step 13: Implementing Customer Communication for Declined Payments

 

Notify customers when their payments are declined:

async function sendDeclineNotification(email, declineCode, failureMessage, paymentIntentId) {
  // Format a user-friendly message based on the decline code
  let userFriendlyMessage = 'Your payment was declined.';
  let actionRequired = '';
  
  switch (declineCode) {
    case 'insufficient\_funds':
      userFriendlyMessage = 'Your payment was declined due to insufficient funds.';
      actionRequired = 'Please ensure you have enough funds in your account or try a different payment method.';
      break;
    
    case 'expired\_card':
      userFriendlyMessage = 'Your payment was declined because the card has expired.';
      actionRequired = 'Please update your card information in your account settings.';
      break;
    
    case 'processing\_error':
      userFriendlyMessage = 'There was a temporary issue processing your payment.';
      actionRequired = 'Please try again in a few moments.';
      break;
    
    default:
      userFriendlyMessage = 'Your payment was declined by your bank.';
      actionRequired = 'For security reasons, specific details are not provided. Please contact your bank or try a different payment method.';
  }
  
  // Create email content
  const emailContent = {
    to: email,
    subject: 'Important: Your payment was not successful',
    text: \`Dear Customer,

${userFriendlyMessage}

${actionRequired}

You can update your payment method or try again by visiting:
https://yourdomain.com/account/payments

If you need assistance, please contact our support team.

Reference: ${paymentIntentId}

Thank you,
Your Company Name\`,
    html: \`

Dear Customer,

${userFriendlyMessage}

${actionRequired}

You can update your payment method or try again by visiting: your account.

If you need assistance, please contact our support team.

Reference: ${paymentIntentId}

Thank you,
Your Company Name

\` }; // Send the email (using your preferred email service) try { // Example with a generic email sender await emailService.send(emailContent); console.log(`Decline notification sent to ${email}`); return true; } catch (error) { console.error('Failed to send decline notification:', error); return false; } }

 

Step 14: Implementing a Retry Payment Page

 

Create a dedicated page for customers to retry failed payments:

// Server-side route handler to render retry payment page
app.get('/retry-payment/:paymentIntentId', async (req, res) => {
  const { paymentIntentId } = req.params;
  
  try {
    // Retrieve the payment intent to get details
    const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
    
    // Only show retry page for failed payments
    if (paymentIntent.status !== 'requires_payment_method' && 
        paymentIntent.status !== 'canceled') {
      return res.redirect('/payment-status');
    }
    
    // Render the retry payment page
    res.render('retry-payment', {
      paymentIntentId: paymentIntentId,
      amount: paymentIntent.amount / 100, // Convert cents to dollars
      currency: paymentIntent.currency,
      customerEmail: paymentIntent.receipt\_email || '',
      clientSecret: paymentIntent.client\_secret
    });
  } catch (error) {
    console.error('Error retrieving payment intent:', error);
    res.status(400).render('error', {
      message: 'Unable to load payment retry page'
    });
  }
});

// Client-side JavaScript for the retry payment page
const stripeElements = stripe.elements();
const card = stripeElements.create('card');
card.mount('#card-element');

const form = document.getElementById('payment-form');
form.addEventListener('submit', async (event) => {
  event.preventDefault();
  
  const submitButton = document.getElementById('submit-button');
  submitButton.disabled = true;
  submitButton.textContent = 'Processing...';
  
  const clientSecret = document.getElementById('client-secret').value;
  
  try {
    const result = await stripe.confirmCardPayment(clientSecret, {
      payment\_method: {
        card: card,
        billing\_details: {
          email: document.getElementById('email').value
        }
      }
    });
    
    if (result.error) {
      // Show error to customer
      const errorElement = document.getElementById('card-errors');
      errorElement.textContent = result.error.message;
      submitButton.disabled = false;
      submitButton.textContent = 'Retry Payment';
    } else {
      // The payment has succeeded
      window.location.href = '/payment-success';
    }
  } catch (error) {
    console.error('Payment confirmation error:', error);
    const errorElement = document.getElementById('card-errors');
    errorElement.textContent = 'An unexpected error occurred. Please try again.';
    submitButton.disabled = false;
    submitButton.textContent = 'Retry Payment';
  }
});

 

Step 15: Implementing a Comprehensive Dashboard for Declined Payments

 

Create an admin dashboard to monitor and manage declined payments:

// Server-side route to get declined payments data
app.get('/api/admin/declined-payments', async (req, res) => {
  try {
    const { startDate, endDate, limit = 50, offset = 0 } = req.query;
    
    // Convert dates to Unix timestamps for Stripe API
    const startTimestamp = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined;
    const endTimestamp = endDate ? Math.floor(new Date(endDate).getTime() / 1000) : undefined;
    
    // Retrieve payment intents with failed status
    const paymentIntents = await stripe.paymentIntents.list({
      limit: parseInt(limit),
      offset: parseInt(offset),
      created: {
        gte: startTimestamp,
        lte: endTimestamp
      }
    });
    
    // Filter only failed payments
    const failedPayments = paymentIntents.data.filter(payment => 
      payment.status === 'requires_payment_method' || payment.last_payment_error
    );
    
    // Transform data for the dashboard
    const formattedPayments = failedPayments.map(payment => {
      const lastError = payment.last_payment_error || {};
      const charge = payment.charges.data[0] || {};
      
      return {
        id: payment.id,
        amount: payment.amount / 100,
        currency: payment.currency,
        customer: payment.customer,
        email: payment.receipt\_email,
        created: new Date(payment.created \* 1000).toISOString(),
        status: payment.status,
        decline_code: charge.failure_code || lastError.decline\_code || null,
        decline_message: charge.failure_message || lastError.message || null,
        payment_method_type: lastError.payment\_method?.type || 'unknown',
        last_attempt: payment.last_payment\_error ? 
                     new Date(payment.last_payment_error.created \* 1000).toISOString() : null,
        can_retry: payment.status === 'requires_payment\_method'
      };
    });
    
    // Aggregate statistics for the dashboard
    const declineStats = {
      total\_count: formattedPayments.length,
      total\_amount: formattedPayments.reduce((sum, p) => sum + p.amount, 0),
      decline\_reasons: formattedPayments.reduce((acc, payment) => {
        const code = payment.decline\_code || 'unknown';
        acc[code] = (acc[code] || 0) + 1;
        return acc;
      }, {})
    };
    
    res.json({
      success: true,
      payments: formattedPayments,
      stats: declineStats,
      has_more: paymentIntents.has_more
    });
  } catch (error) {
    console.error('Error fetching declined payments:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve declined payments'
    });
  }
});

 

Conclusion

 

Effectively handling declined payments with the Stripe API involves understanding the different decline types, implementing proper error handling, and providing actionable feedback to users. By following this comprehensive guide, you can create a robust payment system that gracefully manages payment failures, retries when appropriate, and maintains excellent customer communication throughout the process.

Remember that payment processing best practices also include:

  • Using up-to-date API versions
  • Implementing proper security measures
  • Testing thoroughly with Stripe's test mode
  • Monitoring payment trends to identify recurring issues
  • Keeping your retry logic aligned with card network rules

By implementing these strategies, you'll minimize revenue loss from failed payments while maintaining a positive customer experience.

Want to explore opportunities to work with us?

Connect with our team to unlock the full potential of no-code solutions with a no-commitment consultation!

Book a Free Consultation

Client trust and success are our top priorities

When it comes to serving you, we sweat the little things. That’s why our work makes a big impact.

Rapid Dev was an exceptional project management organization and the best development collaborators I've had the pleasure of working with. They do complex work on extremely fast timelines and effectively manage the testing and pre-launch process to deliver the best possible product. I'm extremely impressed with their execution ability.

CPO, Praction - Arkady Sokolov

May 2, 2023

Working with Matt was comparable to having another co-founder on the team, but without the commitment or cost. He has a strategic mindset and willing to change the scope of the project in real time based on the needs of the client. A true strategic thought partner!

Co-Founder, Arc - Donald Muir

Dec 27, 2022

Rapid Dev are 10/10, excellent communicators - the best I've ever encountered in the tech dev space. They always go the extra mile, they genuinely care, they respond quickly, they're flexible, adaptable and their enthusiasm is amazing.

Co-CEO, Grantify - Mat Westergreen-Thorne

Oct 15, 2022

Rapid Dev is an excellent developer for no-code and low-code solutions.
We’ve had great success since launching the platform in November 2023. In a few months, we’ve gained over 1,000 new active users. We’ve also secured several dozen bookings on the platform and seen about 70% new user month-over-month growth since the launch.

Co-Founder, Church Real Estate Marketplace - Emmanuel Brown

May 1, 2024 

Matt’s dedication to executing our vision and his commitment to the project deadline were impressive. 
This was such a specific project, and Matt really delivered. We worked with a really fast turnaround, and he always delivered. The site was a perfect prop for us!

Production Manager, Media Production Company - Samantha Fekete

Sep 23, 2022