Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 046 / 100

Handling declined payments: patterns, pitfalls and fixes

Help customers succeed on the next try.

Expona Admin9 min read

Introduction

A customer reaches checkout with a full cart, enters their card details, hits pay, and gets a cryptic error: "Your payment was declined." They have no idea why, abandon the order, and you lose the sale. Handling declined payments: patterns, pitfalls and fixes is one of the most underestimated opportunities to recover revenue and build customer trust. The difference between a frustrated abandonment and a successful retry often comes down to clarity, guidance, and options.

Over 15% of legitimate transactions fail on the first attempt due to temporary issues, fraud filters, or genuine card problems. Your job isn't just to reject—it's to help customers understand what went wrong and get them across the finish line on the next try.

Show clear, non-technical messages

When Stripe declines a payment, you get a decline code: card_declined, insufficient_funds, lost_card, processing_error. Don't show these codes to customers. They mean nothing to a non-technical user and feel cold, corporate, unhelpful.

Instead, map each decline reason to a helpful, actionable message in plain English. Here's what that looks like in practice:

typescript
const declineMessageMap: Record<string, string> = {
  'card_declined': 'Your card was declined. Please check the card number, expiry, and CVV, then try again.',
  'insufficient_funds': 'Your card doesn\'t have enough funds. Try a different card or account.',
  'lost_card': 'Your card issuer has flagged this card as lost. Contact your bank.',
  'stolen_card': 'Your card issuer has flagged this card as stolen. Contact your bank immediately.',
  'expired_card': 'Your card has expired. Please use a different card.',
  'incorrect_cvc': 'The security code (CVV) you entered is incorrect. Check the back of your card.',
  'processing_error': 'A temporary error occurred. Please try again in a few moments.',
  'rate_limit': 'Too many attempts. Wait a minute before trying again.',
  'authentication_required': 'Your bank requires extra verification. Check your phone or email for a code.',
  'blocked': 'This transaction was blocked by your bank. Contact them to authorize it.'
};

function getUserMessage(declineCode: string): string {
  return declineMessageMap[declineCode] || 'Your payment could not be processed. Please try again or contact support.';
}

The key is matching the decline reason to the action the customer should take. If it's insufficient_funds, they need a different payment method. If it's authentication_required, they need to complete 3D Secure. If it's processing_error, a simple retry works.

Test these messages with non-technical users. A message that makes sense to a developer might still confuse a customer. Avoid jargon. Use "card" instead of "payment instrument." Use "security code" instead of "CVV" or "CVC"—though you can add the acronym in parentheses for clarity.

Use decline codes to guide users

Stripe decline codes are your compass. Each one tells you something specific about why the transaction failed, and that should drive your UX next.

Some declines are user error (expired card, wrong CVV). Others are bank-side issues (insufficient funds, lost card, fraud blocks). A few are transient (rate_limit, processing_error). Your flow should differ in each case.

typescript
type DeclineCategory = 'user_error' | 'bank_action' | 'transient' | 'auth_required';

function categorizeDecline(declineCode: string): DeclineCategory {
  const userErrorCodes = ['expired_card', 'incorrect_cvc', 'invalid_number'];
  const bankActionCodes = ['lost_card', 'stolen_card', 'insufficient_funds', 'blocked'];
  const transientCodes = ['processing_error', 'rate_limit', 'try_again_later'];
  const authCodes = ['authentication_required'];

  if (userErrorCodes.includes(declineCode)) return 'user_error';
  if (bankActionCodes.includes(declineCode)) return 'bank_action';
  if (authCodes.includes(declineCode)) return 'auth_required';
  return 'transient';
}

function handleDeclineFlow(declineCode: string, context: PaymentContext) {
  const category = categorizeDecline(declineCode);

  switch (category) {
    case 'user_error':
      // Let them retry with corrected details, same card
      return { 
        action: 'retry',
        message: getUserMessage(declineCode),
        allowSameCard: true 
      };

    case 'bank_action':
      // Escalate; suggest a different card or support
      return {
        action: 'switch_card',
        message: getUserMessage(declineCode),
        showAlternatives: true,
        showSupportLink: true
      };

    case 'auth_required':
      // Trigger 3D Secure or redirect to auth flow
      return {
        action: 'authenticate',
        message: 'Your bank requires verification. You\'ll be redirected to complete it.',
        requiresRedirect: true
      };

    case 'transient':
      // Invite immediate retry
      return {
        action: 'retry',
        message: 'A temporary issue occurred. Your card is fine—please try again.',
        allowSameCard: true,
        autoRetryAfterDelay: true
      };
  }
}

For user_error declines, stay on the same form. Let them correct the card number, expiry, or CVV without friction. For bank_action declines, move them away from that card—offering alternatives is critical. For auth_required declines, redirect to 3D Secure or SCA verification without making them feel like something is wrong. For transient declines, a simple "try again" with a 2–5 second delay often works.

This categorization prevents customers from hammering the same broken card a dozen times. It also signals confidence: "we know what happened, and here's what to do."

Offer other payment methods

The moment a card is declined and the reason is bank-side (not user error), offer an escape hatch: a different card, digital wallet, ACH, or whatever payment methods you support.

In an e-commerce or SaaS context, this is your highest-leverage recovery tool. A customer with multiple cards on file can switch in seconds. A new customer can add a different card. Either way, you avoid losing the sale to a single card failure.

Here's a pattern for presenting alternatives:

typescript
interface PaymentAlternative {
  type: 'card' | 'wallet' | 'ach' | 'other';
  label: string;
  available: boolean;
  reason?: string;
}

async function getPaymentAlternatives(customer: Customer): Promise<PaymentAlternative[]> {
  const alternatives: PaymentAlternative[] = [];

  // Saved cards (if not the one that just failed)
  const savedCards = await fetchSavedCards(customer.id);
  for (const card of savedCards) {
    alternatives.push({
      type: 'card',
      label: `${card.brand} ending in ${card.last4}`,
      available: true
    });
  }

  // Apple Pay / Google Pay
  if (supportsDigitalWallets(customer.region)) {
    alternatives.push({
      type: 'wallet',
      label: 'Apple Pay / Google Pay',
      available: true
    });
  }

  // Add a new card
  alternatives.push({
    type: 'card',
    label: 'Add a new card',
    available: true
  });

  // ACH (US/bank transfer)
  if (customer.region === 'US' && acHEnabled) {
    alternatives.push({
      type: 'ach',
      label: 'Bank transfer (ACH)',
      available: true,
      reason: 'Takes 2–3 business days'
    });
  }

  return alternatives;
}

The key is to make switching methods easy and low-friction. Don't make them re-enter their address or confirm their email. Show alternatives as large, tappable buttons. Prioritize methods that are available now (saved cards, wallets) over slower alternatives (ACH, wire).

Stripe's saved payment methods (stored on your customer object) are invaluable here. If a customer has ever successfully paid with a different card, surface it immediately. Recovery is about removing friction, not adding steps.

Never reveal fraud rule details

When Stripe flags a transaction as high-risk and declines it, the decline code might be blocked or the payment intent might have status: 'requires_payment_method' with a setup_future_usage requirement. Customers don't need to know your fraud rules are working overtime.

Never say things like:

  • "Your transaction was flagged by our fraud detection system."
  • "You're making too many purchases in a short time."
  • "This card is linked to a high-risk region."
  • "Our AI flagged this as unusual activity."

These messages create distrust and confusion. They also leak information about your security logic—not a good idea.

Instead, use the generic message: "This transaction was blocked by your bank. Contact them to authorize it." This is true (banks do have fraud filters), honest, and doesn't expose your tooling.

If a customer is genuinely being over-blocked by your fraud rules, your support team can reach out proactively or offer a direct line to escalation. But never hint at fraud detection in the UI. Let Stripe and the customer's bank handle that conversation.

Common mistakes

Mistake 1: Showing Stripe decline codes directly

card_declined or authentication_required means nothing to most users. They'll google it, get confused, and abandon. Map every code to a customer-friendly message. No exceptions.

Mistake 2: Not retrying declined transactions

Some declines are truly transient. A network hiccup, a temporary block, or a rate limit on Stripe's side. If you show an error and don't offer an easy retry, you've guaranteed a lost sale. Build retry into your UX by default.

Mistake 3: Charging the customer multiple times before showing an error

This is a nightmare. Always check the payment intent status before attempting another charge. Use idempotency_key to prevent accidental double-charging if your request is retried.

typescript
async function createPaymentIntentSafely(
  customerId: string,
  amount: number,
  idempotencyKey: string
): Promise<Stripe.PaymentIntent> {
  // Stripe deduplicates by idempotency key within 24 hours
  const intent = await stripe.paymentIntents.create(
    {
      customer: customerId,
      amount,
      currency: 'usd',
      payment_method_types: ['card'],
      confirm: false
    },
    {
      idempotencyKey // Prevents accidental duplicate charges
    }
  );

  return intent;
}

Mistake 4: Forgetting to handle 3D Secure / SCA redirects

If the decline code is authentication_required, you must redirect the customer to complete 3D Secure verification. Trying again with the same card without that step will fail again. Check for requires_action status and handle it.

Mistake 5: Not offering saved payment methods or alternatives

If a customer has a backup card, show it. If they have a digital wallet, show it. Don't trap them on a single payment form. One method failure shouldn't mean cart abandonment.

Key takeaways

  • Map decline codes to plain-English messages. Users don't speak Stripe. Every decline code should translate to an actionable instruction: check your card details, contact your bank, try again in a moment, or switch to a different method.
  • Categorize declines to guide the next step. User errors (wrong CVV) suggest retry on the same card. Bank actions (lost card) suggest switching cards. Transient errors suggest immediate retry. Auth-required declines need a redirect. Each category has a different UX.
  • Offer escape hatches early. The moment a card is declined, surface saved cards, wallets, or other payment methods. Remove friction, not options.
  • Keep fraud rules invisible. Never leak details about your fraud detection logic or Stripe's blocks. Let the customer think their bank is being protective, not your system being restrictive.
  • Test retry and idempotency logic. Transient declines will happen. Make sure retries are safe (no double-charges) and easy (one tap, one message).
  • Monitor decline rates and patterns. Track which decline codes appear most, which payment methods recover best, and where customers drop off. Use that data to refine your messaging and UX over time.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles