How to Handle Stripe Errors in Node.js and TypeScript (StripeCardError, Rate Limits, Webhooks)
By Nark Team
The Stripe Node.js SDK throws five distinct error classes, each requiring different handling. StripeCardError means a card was declined and you should show the user the decline reason. StripeRateLimitError means you hit the API rate limit and should retry with exponential backoff. StripeAuthenticationError means your API key is invalid and you must not retry. StripeConnectionError means a network failure occurred and you should retry. StripeSignatureVerificationError means a webhook payload failed signature verification and you must reject it immediately. Every stripe.paymentIntents.create(), stripe.charges.create(), and stripe.webhooks.constructEvent() call can throw these, and your code needs to handle each case explicitly.
Quick Answer: Wrap every Stripe SDK call in try-catch and check
error.typeto distinguishcard_error,rate_limit_error,authentication_error, andapi_connection_error. For webhooks, always catchStripeSignatureVerificationErrorfromconstructEvent(). To verify that every Stripe call in your codebase has proper error handling:npx nark --tsconfig ./tsconfig.json
What Error Types Does the Stripe SDK Throw?
The Stripe Node.js SDK (v8+) throws typed error classes that extend a base StripeError. According to the Stripe documentation, every API call can throw one of these error types, identified by error.type:
| Error Class | error.type | Cause | Retry? |
|---|---|---|---|
StripeCardError | card_error | Card declined, expired, insufficient funds | No (requires user action) |
StripeRateLimitError | rate_limit_error | Too many API requests | Yes (exponential backoff) |
StripeInvalidRequestError | invalid_request_error | Invalid parameters, missing required fields | No (fix your code) |
StripeAuthenticationError | authentication_error | Invalid or expired API key | No (fix configuration) |
StripeConnectionError | api_connection_error | Network failure, DNS error, timeout | Yes (exponential backoff) |
StripeSignatureVerificationError | (thrown by constructEvent) | Webhook signature mismatch | No (reject the request) |
The key detail most tutorials miss: these are separate error classes, not just string codes. You can use instanceof checks to handle each one differently, which gives you full TypeScript type narrowing.
How to Catch StripeCardError (Card Declined)
StripeCardError is the most common Stripe error in production. It fires when a card is declined, expired, has insufficient funds, or fails a CVC check. The error.decline_code property tells you the specific reason.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createPaymentIntent(amount: number, paymentMethodId: string) {
try {
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
payment_method: paymentMethodId,
confirm: true,
automatic_payment_methods: {
enabled: true,
allow_redirects: 'never',
},
});
return { success: true, paymentIntent };
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
// error.decline_code tells you WHY the card was declined
// Common codes: 'insufficient_funds', 'lost_card', 'stolen_card',
// 'card_declined', 'expired_card', 'incorrect_cvc'
return {
success: false,
error: 'CARD_DECLINED',
declineCode: error.decline_code,
message: error.message, // user-safe message from Stripe
};
}
throw error;
}
}
Do not retry card errors. A declined card will decline again. Return the decline reason to your UI so the user can try a different payment method or contact their bank. Stripe provides human-readable messages in error.message that are safe to show to end users.
Common decline codes and what they mean:
decline_code | Meaning | What to Tell the User |
|---|---|---|
insufficient_funds | Not enough money | "Your card has insufficient funds." |
card_declined | Generic decline | "Your card was declined. Try another card." |
expired_card | Card is expired | "Your card has expired." |
incorrect_cvc | CVC check failed | "The security code is incorrect." |
lost_card | Card reported lost | "Your card was declined. Contact your bank." |
processing_error | Temporary issue | "A processing error occurred. Try again." |
How to Handle Stripe Rate Limits with Exponential Backoff
Stripe rate limits are per-account, not per-API-key. The default limit is 100 requests per second in live mode and 25 in test mode. When you exceed this, every API call throws StripeRateLimitError.
async function createCustomerWithRetry(
email: string,
maxRetries = 3
): Promise<Stripe.Customer> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await stripe.customers.create({ email });
} catch (error) {
if (
error instanceof Stripe.errors.StripeRateLimitError &&
attempt < maxRetries
) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw new Error('Unreachable');
}
Rate limit errors are safe to retry because they indicate a temporary condition. The Stripe documentation recommends exponential backoff with a maximum of 3 retries. If you are still hitting rate limits after backoff, you are sending too many requests and need to restructure your code (batch operations, queue requests, or cache results).
How to Handle StripeAuthenticationError (Invalid API Key)
StripeAuthenticationError means your API key is invalid, expired, or does not have permission for the requested resource. This is a configuration error, not a runtime error.
async function listCustomers() {
try {
return await stripe.customers.list({ limit: 10 });
} catch (error) {
if (error instanceof Stripe.errors.StripeAuthenticationError) {
// DO NOT retry. Log and alert your operations team.
console.error(
'Stripe authentication failed. Check STRIPE_SECRET_KEY.',
{ type: error.type, message: error.message }
);
throw new Error('Payment service configuration error');
}
throw error;
}
}
Never retry authentication errors. They will fail identically every time. Common causes: you deployed with a test key instead of a live key, a key was rotated and the environment variable was not updated, or you are using a restricted key that does not have permission for the endpoint you called.
How to Handle Network and Connection Errors
StripeConnectionError fires when the SDK cannot reach Stripe's servers. This includes DNS failures, TCP connection refused, TLS handshake failures, and request timeouts.
async function chargeWithNetworkRetry(
paymentIntentId: string,
maxRetries = 3
): Promise<Stripe.PaymentIntent> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await stripe.paymentIntents.confirm(paymentIntentId);
} catch (error) {
// Network errors: safe to retry
if (
error instanceof Stripe.errors.StripeConnectionError &&
attempt < maxRetries
) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
// Card errors: do NOT retry
if (error instanceof Stripe.errors.StripeCardError) {
return { success: false, declineCode: error.decline_code } as any;
}
throw error;
}
}
throw new Error('Stripe unreachable after retries');
}
Critical for payment confirmations: When retrying a paymentIntents.confirm() after a network error, you must check whether the payment actually went through before creating a new attempt. Use stripe.paymentIntents.retrieve(id) to check the status. Otherwise you risk double-charging the customer. This is why Stripe recommends using idempotency keys on every mutating request.
How to Handle Stripe Webhook Signature Verification
Webhooks are where most Stripe security vulnerabilities occur. stripe.webhooks.constructEvent() verifies the signature and throws StripeSignatureVerificationError if the payload has been tampered with or the webhook secret is wrong.
import Stripe from 'stripe';
import { Request, Response } from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function handleWebhook(req: Request, res: Response) {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (error) {
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
// Signature invalid - possible tampering or wrong secret
console.error('Webhook signature verification failed:', error.message);
res.status(400).json({ error: 'Invalid signature' });
return;
}
// Unexpected error
console.error('Webhook error:', error);
res.status(500).json({ error: 'Internal error' });
return;
}
// Signature verified - safe to process
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data.object as Stripe.PaymentIntent);
break;
case 'invoice.payment_failed':
await handleInvoiceFailure(event.data.object as Stripe.Invoice);
break;
}
res.status(200).json({ received: true });
}
Never skip signature verification. Without it, anyone can POST fake webhook events to your endpoint and trigger actions like granting premium access, marking invoices as paid, or modifying subscriptions. According to the Stripe documentation, this is one of the most common integration security flaws.
Important: Your webhook endpoint must receive the raw request body (not parsed JSON) for signature verification to work. In Express, use express.raw({ type: 'application/json' }) on the webhook route. In Next.js App Router, read the raw body from the request. If you parse the body before constructEvent, the signature will never match.
The Complete Error Handling Pattern
Here is the production-grade pattern that handles all five Stripe error types:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
type PaymentResult =
| { success: true; paymentIntent: Stripe.PaymentIntent }
| { success: false; error: string; retryable: boolean; detail?: string };
async function processPayment(
amount: number,
paymentMethodId: string,
customerId: string,
idempotencyKey: string
): Promise<PaymentResult> {
try {
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency: 'usd',
customer: customerId,
payment_method: paymentMethodId,
confirm: true,
automatic_payment_methods: {
enabled: true,
allow_redirects: 'never',
},
},
{ idempotencyKey }
);
return { success: true, paymentIntent };
} catch (error) {
// Card declined - show user the reason
if (error instanceof Stripe.errors.StripeCardError) {
return {
success: false,
error: 'CARD_DECLINED',
retryable: false,
detail: error.message,
};
}
// Rate limited - caller should retry
if (error instanceof Stripe.errors.StripeRateLimitError) {
return {
success: false,
error: 'RATE_LIMITED',
retryable: true,
};
}
// Invalid request - bug in our code
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
console.error('Stripe invalid request:', {
param: error.param,
message: error.message,
});
return {
success: false,
error: 'PAYMENT_ERROR',
retryable: false,
};
}
// Auth error - configuration problem
if (error instanceof Stripe.errors.StripeAuthenticationError) {
console.error('Stripe auth failed - check API keys');
return {
success: false,
error: 'SERVICE_ERROR',
retryable: false,
};
}
// Network error - retry with backoff
if (error instanceof Stripe.errors.StripeConnectionError) {
return {
success: false,
error: 'NETWORK_ERROR',
retryable: true,
};
}
// Unknown error
console.error('Unexpected Stripe error:', error);
return {
success: false,
error: 'UNKNOWN_ERROR',
retryable: false,
};
}
}
Key patterns in this code:
- Idempotency key on every mutating call. Prevents double-charges when retrying after network errors.
- Typed result union. Callers know whether to show a user message, retry, or alert ops.
instanceofchecks instead oferror.typestrings. Full TypeScript type narrowing.- Never expose raw Stripe errors to the client. Return structured error codes.
Handling Stripe Errors in Next.js API Routes
Next.js API Routes (both Pages Router and App Router) need explicit error handling because unhandled exceptions crash the serverless function.
// app/api/checkout/route.ts (App Router)
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { priceId, customerId } = await req.json();
try {
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
return NextResponse.json(
{ error: 'Invalid checkout parameters' },
{ status: 400 }
);
}
if (error instanceof Stripe.errors.StripeAuthenticationError) {
console.error('Stripe authentication failed');
return NextResponse.json(
{ error: 'Payment service unavailable' },
{ status: 503 }
);
}
if (error instanceof Stripe.errors.StripeRateLimitError) {
return NextResponse.json(
{ error: 'Too many requests, please try again' },
{ status: 429 }
);
}
console.error('Unexpected Stripe error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Map each Stripe error type to the correct HTTP status code: 402 for card errors, 429 for rate limits, 400 for invalid requests, 503 for auth/connection errors.
Frequently Asked Questions
Does the Stripe Node.js SDK Automatically Retry Failed Requests?
Yes, since stripe-node v8. The SDK retries network errors and rate limit errors up to 2 times by default with exponential backoff. You can configure this with maxNetworkRetries:
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
maxNetworkRetries: 3, // default is 2
});
The SDK does NOT retry card errors, authentication errors, or invalid request errors. If you need custom retry logic (for example, different backoff for rate limits vs network errors), disable automatic retries with maxNetworkRetries: 0 and implement your own.
Should I use error.type or instanceof to check Stripe errors?
Use instanceof. It gives you full TypeScript type narrowing:
// instanceof - TypeScript knows error is StripeCardError
if (error instanceof Stripe.errors.StripeCardError) {
console.log(error.decline_code); // TypeScript knows this exists
}
// error.type - TypeScript still types error as unknown
if ((error as any).type === 'card_error') {
console.log((error as any).decline_code); // no type safety
}
What is an idempotency key and when should I use one?
An idempotency key is a unique string you pass with a Stripe API call. If the same key is sent twice, Stripe returns the original result instead of processing the request again. Use idempotency keys on every mutating request (create, confirm, refund) to prevent double-charges after network failures or retries.
const paymentIntent = await stripe.paymentIntents.create(
{ amount: 2000, currency: 'usd' },
{ idempotencyKey: `order_${orderId}` }
);
How to Test Stripe Error Handling with Test Card Numbers
Stripe provides test card numbers that trigger specific errors in test mode. Use these to verify every error branch in your code without processing real payments.
| Card Number | decline_code | Error Type | What It Tests |
|---|---|---|---|
4000000000000002 | card_declined | StripeCardError | Generic decline |
4000000000009995 | insufficient_funds | StripeCardError | Insufficient funds |
4000000000000069 | expired_card | StripeCardError | Expired card |
4000000000000127 | incorrect_cvc | StripeCardError | CVC check failure |
4000000000000119 | processing_error | StripeCardError | Processing error (retryable) |
4000000000004954 | stolen_card | StripeCardError | Stolen card |
4000000000000341 | (attach succeeds, charge fails) | StripeCardError | Delayed decline on PaymentIntent |
4000000000000101 | incorrect_number | StripeInvalidRequestError | Invalid card number |
4100000000000019 | (blocked by Radar) | StripeCardError | Fraud detection block |
Use the test card in a paymentIntents.create call to exercise your error paths:
// In your test suite or manual testing
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000,
currency: 'usd',
payment_method_data: {
type: 'card',
card: { token: 'tok_chargeDeclined' }, // or use the test card number
},
confirm: true,
});
// This will throw StripeCardError with decline_code: 'card_declined'
For webhook testing, use the Stripe CLI to forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger payment_intent.payment_failed
This lets you test your constructEvent signature verification and event handling without deploying.
Automating Stripe Error Handling Checks
Manually reviewing every stripe.* call in a growing codebase is error-prone. One unprotected stripe.paymentIntents.create() can silently swallow a declined card and leave a customer thinking they paid when they did not.
Nark automates this by scanning your TypeScript AST for Stripe SDK calls that lack proper error handling. Its Stripe profile covers create, confirm, constructEvent, retrieve, capture, cancel, refund, attach, detach, and 9 more API methods, each with the specific error types that call can throw.
npx nark --tsconfig ./tsconfig.json
Nark flags two patterns:
- Bare Stripe calls with no try-catch (the silent failure risk)
- Catch blocks that do not check the specific error type (the "everything is a 500" problem)
The output tells you which file, which line, and which specific error type is unhandled. In a scan of 36 open-source repos using Stripe, Nark found that 84% had at least one unprotected Stripe call. The most common miss: constructEvent() without catching StripeSignatureVerificationError.
Stripe Error Handling Best Practices
These patterns separate production-grade Stripe integrations from tutorial-quality code:
-
Use
instanceof Stripe.errors.*for type-safe error checks. This gives you full TypeScript narrowing and access to error-specific properties likedecline_codeandparam. -
Never retry card errors or authentication errors. Card declines require user action. Auth errors require configuration fixes. Only retry rate limit and connection errors.
-
Always pass idempotency keys on mutating requests. This prevents double-charges when network errors trigger retries. Derive the key from your domain (order ID, transaction ID) so it is naturally unique and replayable.
-
Always verify webhook signatures. Call
stripe.webhooks.constructEvent()with the raw body and catchStripeSignatureVerificationError. Never process a webhook event without verification. -
Map Stripe error types to HTTP status codes. 402 for card errors, 429 for rate limits, 400 for invalid requests, 503 for auth and connection errors. Your API consumers need structured errors, not generic 500s.
-
Log Stripe errors with context but never expose raw errors to clients. Log
error.type,error.code,error.param, anderror.decline_codeserver-side. Return a structured error code and a user-safe message to the client. -
Use the raw request body for webhook verification. Parsing the body before
constructEventbreaks signature verification. In Express:express.raw(). In Next.js: read from the request stream. -
Automate coverage checks. Run
npx nark --tsconfig ./tsconfig.jsonto find every unprotected Stripe call before a declined card silently drops a payment in production.
Try It Now
npx nark --tsconfig ./tsconfig.json
Nark checks 160+ packages — including axios, prisma, stripe, and redis — for correct error handling, resource cleanup, and profile violations. Its Stripe profile covers 18 API methods across payment intents, charges, customers, webhooks, invoices, and payment methods, each with the specific error classes documented above. Run it against your project to find every unhandled Stripe call before your customers find them for you.