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">✓</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 (
StripeErrorfrom@stripe/stripe-js) and server-side errors (Stripe.errors.StripeErrorfromstripe) have different type hierarchies but similar categories. card_erroris the most common type. It means the card was declined by the issuing bank. Thecodefield provides the specific reason (insufficient funds, expired, etc.).validation_erroroccurs 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_actionstatus indicates the flow is in progress. api_errorandapi_connection_errorare transient. These are safe to retry because Stripe operations are idempotent when you pass anidempotencyKey.confirmPaymentwithredirect: "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
StripeErroris imported from@stripe/stripe-js. - Server-side errors extend
Stripe.errors.StripeErrorfrom thestripepackage. - The
error.codeproperty on card errors is typed asstring | 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.
confirmPaymentmay redirect the user for 3D Secure. When they return, check the PaymentIntent status via thepayment_intentquery parameter.- The
requires_actionstatus 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_errormay 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.StripeErrorspecifically. Other errors (like network timeouts) need different handling. - Do not retry
card_errororinvalid_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 Type | Cause | Retryable | User Action |
|---|---|---|---|
| card_error | Card declined by issuer | No | Use different card |
| validation_error | Invalid form input | No | Fix input fields |
| invalid_request_error | Bad API parameters | No | Fix code or input |
| api_error | Stripe server issue | Yes | Retry after delay |
| api_connection_error | Network failure | Yes | Check connection, retry |
| authentication_error | Invalid API key | No | Fix configuration |
| rate_limit_error | Too many requests | Yes | Retry with backoff |
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Custom error mapping | Full control over messages | Must maintain error map |
| Stripe's default messages | Less code, always up to date | Too technical for end users |
| Error boundary + fallback | Catches unexpected React errors | Does not catch Stripe.js errors |
| Toast notifications | Non-blocking error display | Easy to miss for payment errors |
FAQs
What are the two different Stripe error types and where does each apply?
- Client-side errors use
StripeErrorfrom@stripe/stripe-js(used in browser code). - Server-side errors use
Stripe.errors.StripeErrorfrom thestripeNode package. - They have different type hierarchies but cover similar error categories.
Which Stripe error types are safe to retry and which are not?
api_errorandapi_connection_errorare transient and safe to retry.rate_limit_erroris retryable with backoff.card_error,validation_error, andinvalid_request_errorare 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_actionstatus 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_actionmeans 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_urlwith apayment_intentquery 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.
retryCountis 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.codeproperty on card errors isstring | 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_errorcan 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.StripeErrorto 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.