React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripeerrors3D SecureSCAdeclinedretry

Error Handling & Edge Cases

Recipe

Handle Stripe errors gracefully by categorizing error types, displaying user-friendly messages, managing 3D Secure authentication flows, and implementing retry logic for transient failures.

Define an error handler utility:

// lib/stripe-errors.ts
import type { StripeError } from "@stripe/stripe-js";
import type Stripe from "stripe";
 
export function getClientErrorMessage(error: StripeError): string {
  switch (error.type) {
    case "card_error":
      return getCardErrorMessage(error.code);
    case "validation_error":
      return "Please check your payment details and try again.";
    case "invalid_request_error":
      return "Something went wrong. Please try again.";
    case "api_error":
      return "Our payment processor is temporarily unavailable. Please try again in a moment.";
    case "api_connection_error":
      return "Network error. Please check your connection and try again.";
    case "authentication_error":
      return "Authentication failed. Please try again.";
    case "rate_limit_error":
      return "Too many requests. Please wait a moment and try again.";
    default:
      return "An unexpected error occurred. Please try again.";
  }
}
 
function getCardErrorMessage(code: string | undefined): string {
  switch (code) {
    case "card_declined":
      return "Your card was declined. Please try a different card.";
    case "insufficient_funds":
      return "Insufficient funds. Please try a different card.";
    case "expired_card":
      return "Your card has expired. Please use a different card.";
    case "incorrect_cvc":
      return "Incorrect CVC. Please check and try again.";
    case "incorrect_number":
      return "Incorrect card number. Please check and try again.";
    case "processing_error":
      return "An error occurred while processing your card. Please try again.";
    default:
      return "Your card was declined. Please try a different payment method.";
  }
}
 
export function getServerErrorMessage(error: Stripe.errors.StripeError): string {
  switch (error.type) {
    case "StripeCardError":
      return error.message ?? "Card error.";
    case "StripeInvalidRequestError":
      return "Invalid request. Please contact support.";
    case "StripeAPIError":
      return "Payment service temporarily unavailable.";
    case "StripeConnectionError":
      return "Unable to connect to payment service.";
    case "StripeAuthenticationError":
      return "Payment configuration error. Please contact support.";
    case "StripeRateLimitError":
      return "Too many requests. Please try again later.";
    default:
      return "An unexpected error occurred.";
  }
}

Handle 3D Secure / SCA authentication:

// lib/confirm-payment.ts
import type { Stripe, StripeElements } from "@stripe/stripe-js";
 
interface PaymentResult {
  success: boolean;
  error?: string;
  requiresAction?: boolean;
}
 
export async function confirmPaymentWithSCA(
  stripe: Stripe,
  elements: StripeElements,
  returnUrl: string
): Promise<PaymentResult> {
  const { error: submitError } = await elements.submit();
  if (submitError) {
    return { success: false, error: submitError.message };
  }
 
  const { error, paymentIntent } = await stripe.confirmPayment({
    elements,
    confirmParams: { return_url: returnUrl },
    redirect: "if_required",
  });
 
  if (error) {
    return { success: false, error: error.message };
  }
 
  switch (paymentIntent?.status) {
    case "succeeded":
      return { success: true };
    case "processing":
      return {
        success: false,
        error: "Your payment is being processed. You will be notified when it completes.",
      };
    case "requires_action":
      // 3D Secure was triggered but not completed
      return {
        success: false,
        requiresAction: true,
        error: "Additional authentication is required.",
      };
    default:
      return { success: false, error: "Unexpected payment status." };
  }
}

Working Example

// app/checkout/secure-checkout-form.tsx
"use client";
 
import { useState, type FormEvent } from "react";
import {
  PaymentElement,
  useStripe,
  useElements,
} from "@stripe/react-stripe-js";
import { getClientErrorMessage } from "@/lib/stripe-errors";
import type { StripeError } from "@stripe/stripe-js";
 
type FormStatus = "idle" | "validating" | "processing" | "succeeded" | "failed";
 
export function SecureCheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [status, setStatus] = useState<FormStatus>("idle");
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const [retryCount, setRetryCount] = useState(0);
 
  function handleError(error: StripeError) {
    const message = getClientErrorMessage(error);
    setErrorMessage(message);
    setStatus("failed");
 
    // Allow retry for transient errors
    if (error.type === "api_error" || error.type === "api_connection_error") {
      setRetryCount((prev) => prev + 1);
    }
  }
 
  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return;
 
    setStatus("validating");
    setErrorMessage(null);
 
    // Step 1: Validate the form
    const { error: submitError } = await elements.submit();
    if (submitError) {
      handleError(submitError);
      return;
    }
 
    setStatus("processing");
 
    // Step 2: Confirm the payment
    const { error: confirmError, paymentIntent } =
      await stripe.confirmPayment({
        elements,
        confirmParams: {
          return_url: `${window.location.origin}/success`,
        },
        redirect: "if_required",
      });
 
    if (confirmError) {
      handleError(confirmError);
      return;
    }
 
    // Step 3: Handle the result
    switch (paymentIntent?.status) {
      case "succeeded":
        setStatus("succeeded");
        break;
      case "processing":
        setErrorMessage(
          "Your payment is being processed. We will notify you when it completes."
        );
        setStatus("processing");
        break;
      case "requires_action":
        setErrorMessage(
          "Additional authentication required. Please complete the verification."
        );
        setStatus("failed");
        break;
      default:
        setErrorMessage("Something went wrong. Please try again.");
        setStatus("failed");
    }
  }
 
  if (status === "succeeded") {
    return (
      <div className="text-center p-8">
        <div className="text-green-500 text-5xl mb-4">&#10003;</div>
        <h2 className="text-2xl font-bold">Payment Successful!</h2>
        <p className="text-gray-600 mt-2">
          Thank you for your purchase.
        </p>
      </div>
    );
  }
 
  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto p-6 space-y-6">
      <PaymentElement />
 
      {errorMessage && (
        <div
          className={`rounded-lg p-4 ${
            status === "processing"
              ? "bg-yellow-50 border border-yellow-200"
              : "bg-red-50 border border-red-200"
          }`}
          role="alert"
        >
          <p
            className={`text-sm font-medium ${
              status === "processing" ? "text-yellow-800" : "text-red-800"
            }`}
          >
            {errorMessage}
          </p>
        </div>
      )}
 
      <button
        type="submit"
        disabled={
          !stripe || status === "validating" || status === "processing"
        }
        className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium
                   hover:bg-blue-700 disabled:opacity-50 transition-colors"
      >
        {status === "validating" && "Validating..."}
        {status === "processing" && "Processing payment..."}
        {status === "idle" && "Pay now"}
        {status === "failed" && (retryCount > 0 ? `Retry (${retryCount})` : "Try again")}
      </button>
 
      {retryCount >= 3 && (
        <p className="text-sm text-gray-500 text-center">
          Having trouble?{" "}
          <a href="/support" className="text-blue-600 underline">
            Contact support
          </a>
        </p>
      )}
    </form>
  );
}

Server-side error handling:

// app/actions/safe-checkout.ts
"use server";
 
import { stripe } from "@/lib/stripe";
import { getServerErrorMessage } from "@/lib/stripe-errors";
import Stripe from "stripe";
 
export async function safeCreateCheckout(priceId: string) {
  try {
    const session = await stripe.checkout.sessions.create({
      mode: "payment",
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    });
 
    return { url: session.url };
  } catch (err) {
    if (err instanceof Stripe.errors.StripeError) {
      console.error(`Stripe error [${err.type}]: ${err.message}`);
      return { error: getServerErrorMessage(err) };
    }
    console.error("Unexpected error:", err);
    return { error: "An unexpected error occurred." };
  }
}

Deep Dive

How It Works

  • Stripe errors fall into distinct categories. Client-side errors (StripeError from @stripe/stripe-js) and server-side errors (Stripe.errors.StripeError from stripe) have different type hierarchies but similar categories.
  • card_error is the most common type. It means the card was declined by the issuing bank. The code field provides the specific reason (insufficient funds, expired, etc.).
  • validation_error occurs when the user's input fails Stripe.js client-side validation (invalid card number format, missing fields).
  • 3D Secure (SCA) is triggered automatically when the card issuer requires it. Stripe handles the redirect to the bank's authentication page and back. The requires_action status indicates the flow is in progress.
  • api_error and api_connection_error are transient. These are safe to retry because Stripe operations are idempotent when you pass an idempotencyKey.
  • confirmPayment with redirect: "if_required" only redirects for payment methods that require it (like 3D Secure). For simple card payments, it resolves in-place.

Variations

Retry with exponential backoff (server-side):

async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (
        err instanceof Stripe.errors.StripeError &&
        (err.type === "StripeConnectionError" ||
          err.type === "StripeAPIError") &&
        attempt < maxRetries
      ) {
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }
      throw err;
    }
  }
  throw new Error("Max retries exceeded");
}
 
// Usage
const session = await withRetry(() =>
  stripe.checkout.sessions.create({ /* ... */ })
);

Idempotent requests:

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 2999,
    currency: "usd",
    automatic_payment_methods: { enabled: true },
  },
  {
    idempotencyKey: `pi_${userId}_${orderId}`,
  }
);

TypeScript Notes

  • Client-side StripeError is imported from @stripe/stripe-js.
  • Server-side errors extend Stripe.errors.StripeError from the stripe package.
  • The error.code property on card errors is typed as string | undefined. Use a switch statement with string literals for type narrowing.
import type { StripeError } from "@stripe/stripe-js";
 
function isRetryable(error: StripeError): boolean {
  return (
    error.type === "api_error" || error.type === "api_connection_error"
  );
}

Gotchas

  • Never show raw Stripe error messages to users in production. They can contain technical details. Always map to user-friendly messages.
  • confirmPayment may redirect the user for 3D Secure. When they return, check the PaymentIntent status via the payment_intent query parameter.
  • The requires_action status does not mean the payment failed. It means the user must complete an additional step (like 3D Secure). Do not show a failure message for this status.
  • api_connection_error may be caused by the user's network, not Stripe's servers. Retrying may not help if the user is offline.
  • On the server side, always catch Stripe.errors.StripeError specifically. Other errors (like network timeouts) need different handling.
  • Do not retry card_error or invalid_request_error. These are permanent failures that require user action (different card or corrected input).
  • Rate limit errors (rate_limit_error) should be handled with backoff. Stripe's rate limit is 100 requests per second in live mode.

Stripe Error Types Reference

Error TypeCauseRetryableUser Action
card_errorCard declined by issuerNoUse different card
validation_errorInvalid form inputNoFix input fields
invalid_request_errorBad API parametersNoFix code or input
api_errorStripe server issueYesRetry after delay
api_connection_errorNetwork failureYesCheck connection, retry
authentication_errorInvalid API keyNoFix configuration
rate_limit_errorToo many requestsYesRetry with backoff

Alternatives

ApproachProsCons
Custom error mappingFull control over messagesMust maintain error map
Stripe's default messagesLess code, always up to dateToo technical for end users
Error boundary + fallbackCatches unexpected React errorsDoes not catch Stripe.js errors
Toast notificationsNon-blocking error displayEasy to miss for payment errors

FAQs

What are the two different Stripe error types and where does each apply?
  • Client-side errors use StripeError from @stripe/stripe-js (used in browser code).
  • Server-side errors use Stripe.errors.StripeError from the stripe Node package.
  • They have different type hierarchies but cover similar error categories.
Which Stripe error types are safe to retry and which are not?
  • api_error and api_connection_error are transient and safe to retry.
  • rate_limit_error is retryable with backoff.
  • card_error, validation_error, and invalid_request_error are permanent failures requiring user action.
How does 3D Secure (SCA) authentication work with confirmPayment?
  • 3D Secure is triggered automatically when the card issuer requires it.
  • Using redirect: "if_required" only redirects for payment methods that need it.
  • A requires_action status means the user must complete an additional step -- it does not mean the payment failed.
Why should you never display raw Stripe error messages to users?
  • Raw Stripe errors can contain technical details about your integration.
  • Always map errors to user-friendly messages using a utility like getClientErrorMessage.
  • This improves both security and user experience.
How does the retry logic with exponential backoff work?
async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (isRetryableStripeError(err) && attempt < maxRetries) {
        await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
        continue;
      }
      throw err;
    }
  }
  throw new Error("Max retries exceeded");
}
What is the purpose of idempotency keys when retrying Stripe requests?
  • Idempotency keys ensure that retrying a request does not create duplicate charges or resources.
  • Pass a unique key like pi_${userId}_${orderId} in the options object.
  • Stripe returns the same result for repeated requests with the same idempotency key.
Gotcha: What happens if you show a failure message when paymentIntent status is "requires_action"?
  • The user sees a misleading error even though the payment is not actually failed.
  • requires_action means the user must complete 3D Secure authentication.
  • Treat it as an in-progress state, not a failure.
Gotcha: What happens when confirmPayment redirects for 3D Secure and the user returns?
  • The user is redirected back to your return_url with a payment_intent query parameter.
  • You must check the PaymentIntent status on the return page to confirm success.
  • Failing to do this can leave the user on a page that does not reflect the actual payment outcome.
How does the FormStatus type help manage the checkout form state?
type FormStatus = "idle" | "validating" | "processing" | "succeeded" | "failed";
  • It restricts status to five known states, preventing invalid UI combinations.
  • The button text and error styling change based on the current status.
  • retryCount is only incremented for transient error types.
How are TypeScript types different between client and server Stripe errors?
// Client-side
import type { StripeError } from "@stripe/stripe-js";
 
// Server-side
import type Stripe from "stripe";
// Stripe.errors.StripeError
  • The error.code property on card errors is string | undefined.
  • Use a switch on string literals for type narrowing.
When does api_connection_error not benefit from retrying?
  • When the user is offline, retrying will keep failing.
  • api_connection_error can be caused by the user's network, not just Stripe's servers.
  • Consider showing a "check your connection" message alongside the retry option.
How does the server-side safeCreateCheckout function handle both Stripe and non-Stripe errors?
  • It uses instanceof Stripe.errors.StripeError to identify Stripe-specific errors.
  • Stripe errors are logged with their type and mapped to a user-friendly message via getServerErrorMessage.
  • All other errors fall through to a generic "unexpected error" response.