Every payment integration will eventually hit a declined card. The question is not if — it is what your users see when it happens.
A generic "payment failed" message leaves users confused and more likely to abandon. A well-handled decline tells them exactly why it failed and what to do next. The difference between these two outcomes is how thoroughly you tested your decline handling before shipping.
This guide walks through every card decline scenario, what each means at the network level, and how to simulate all of them using the MockCard API so your error handling is bulletproof before a single real card gets rejected.
Why Payment Declines Are Not All the Same
Payment networks return specific decline codes for a reason. Each code maps to a different user action — and your UI should reflect that.
| Decline Code | Meaning | Correct User Action |
|---|---|---|
insufficient_funds |
Card balance too low | Try a different card |
do_not_honor |
Issuer blocked the charge | Contact your bank |
expired_card |
Card past expiry date | Update card details |
incorrect_cvv |
CVV mismatch | Re-enter card details |
If you show the same error message for all four, users with an expired card will keep retrying — and users with insufficient funds will call your support team instead of switching cards.
Setting Up MockCard for Decline Testing
MockCard generates Luhn-valid test card numbers and simulates real gateway responses — including the exact decline codes your payment processor would return.
Get a free API key at mockcard.io and make your first request:
curl -X POST https://mockcard.io/api/v1/generate \\
-H "Content-Type: application/json" \\
-H "X-Api-Key: YOUR_API_KEY" \\
-d '{"brand": "visa", "scenario": "insufficient_funds"}'
Response:
{
"status": "error",
"scenario": "insufficient_funds",
"card": {
"card_number": "4532015112830366",
"expiry_month": "08",
"expiry_year": "2027",
"cvv": "123",
"brand": "visa"
},
"webhook_event": {
"type": "payment_intent.payment_failed",
"data": {
"object": {
"status": "requires_payment_method",
"last_payment_error": {
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds."
}
}
}
}
}
The response mirrors the exact structure of a Stripe payment_intent.payment_failed event — so you can wire it directly into your existing error handling logic.
Scenario 1 — Insufficient Funds
What it means: The card has insufficient balance or credit limit to cover the charge.
What users should do: Try a different payment method.
Simulate it:
curl -X POST https://mockcard.io/api/v1/generate \\
-H "X-Api-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"brand": "mastercard", "scenario": "insufficient_funds"}'
How to handle it in code (TypeScript):
if (error.decline_code === "insufficient_funds") {
return {
message: "Your card has insufficient funds.",
action: "Please try a different card or payment method.",
retryable: false,
};
}
Key rule: Do not suggest retrying the same card. Route users immediately to an alternate payment method.
Scenario 2 — Do Not Honor
What it means: The issuing bank declined the transaction without specifying a reason. This is the most generic decline and can mean anything from a fraud flag to a temporary bank restriction.
What users should do: Contact their bank or try a different card.
Simulate it:
curl -X POST https://mockcard.io/api/v1/generate \\
-H "X-Api-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"brand": "visa", "scenario": "do_not_honor"}'
How to handle it:
if (error.decline_code === "do_not_honor") {
return {
message: "Your bank declined this transaction.",
action: "Please contact your bank or try a different card.",
retryable: false,
};
}
Key rule: Never tell users a do_not_honor is a temporary error and to "try again." Banks flag repeated do_not_honor retries as fraud, which can get your merchant account flagged.
Scenario 3 — Expired Card
What it means: The card's expiry date has passed.
What users should do: Update their card details.
Simulate it:
curl -X POST https://mockcard.io/api/v1/generate \\
-H "X-Api-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"brand": "amex", "scenario": "expired_card"}'
How to handle it:
if (error.decline_code === "expired_card") {
return {
message: "Your card has expired.",
action: "Please update your card details and try again.",
retryable: true,
};
}
Key rule: This is the one decline where retryable: true makes sense — the user can immediately fix it by entering a valid card.
Scenario 4 — Incorrect CVV
What it means: The CVV/CVC entered does not match what the bank has on file.
What users should do: Double-check and re-enter their card details.
Simulate it:
curl -X POST https://mockcard.io/api/v1/generate \\
-H "X-Api-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"brand": "rupay", "scenario": "incorrect_cvv"}'
How to handle it:
if (error.decline_code === "incorrect_cvv") {
return {
message: "The security code on your card is incorrect.",
action: "Please check the 3-digit code on the back of your card.",
retryable: true,
};
}
Building a Unified Decline Handler
Rather than scattering if blocks across your codebase, build a single decline handler you can test exhaustively:
type DeclineCode =
| "insufficient_funds"
| "do_not_honor"
| "expired_card"
| "incorrect_cvv";
interface DeclineMessage {
message: string;
action: string;
retryable: boolean;
}
const DECLINE_MESSAGES: Record<DeclineCode, DeclineMessage> = {
insufficient_funds: {
message: "Your card has insufficient funds.",
action: "Please try a different payment method.",
retryable: false,
},
do_not_honor: {
message: "Your bank declined this transaction.",
action: "Please contact your bank or use a different card.",
retryable: false,
},
expired_card: {
message: "Your card has expired.",
action: "Please update your card details.",
retryable: true,
},
incorrect_cvv: {
message: "Incorrect security code.",
action: "Please check the CVV on the back of your card.",
retryable: true,
},
};
function handleDecline(declineCode: string): DeclineMessage {
return (
DECLINE_MESSAGES[declineCode as DeclineCode] ?? {
message: "Your payment could not be processed.",
action: "Please try a different payment method.",
retryable: false,
}
);
}
Now use MockCard to drive your test suite across all four paths before shipping:
const scenarios: DeclineCode[] = [
"insufficient_funds",
"do_not_honor",
"expired_card",
"incorrect_cvv",
];
for (const scenario of scenarios) {
const result = await fetch("https://mockcard.io/api/v1/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": process.env.MOCKCARD_API_KEY!,
},
body: JSON.stringify({ brand: "visa", scenario }),
});
const data = await result.json();
const decline = data.webhook_event.data.object.last_payment_error;
const handled = handleDecline(decline.decline_code);
console.assert(handled.message.length > 0, `No message for ${scenario}`);
}
Testing Webhooks for Decline Events (Pro)
If you use webhooks to update order status on declined payments, MockCard fires a payment_intent.payment_failed event to your endpoint on every decline scenario. Pass your webhook URL in the request:
curl -X POST https://mockcard.io/api/v1/generate \\
-H "X-Api-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"brand": "visa",
"scenario": "insufficient_funds",
"webhook_url": "https://your-app.com/webhooks/payment"
}'
MockCard signs the payload with X-MockCard-Signature: sha256=... — the same HMAC-SHA256 pattern Stripe uses — so your existing signature verification works unchanged.
Note: Webhook delivery requires a Pro account. Card generation and decline simulation are free.
Pre-Ship Checklist for Decline Handling
- Each decline code shows a distinct, actionable error message
-
do_not_honorandinsufficient_fundsdo not offer a "try again" button -
expired_cardandincorrect_cvvroute users to update their card details - Webhook handler marks orders correctly on
payment_intent.payment_failed - Unknown decline codes fall back to a safe generic message
- Retry logic does not loop on non-retryable codes
- All 4 scenarios tested across Visa, Mastercard, RuPay, and Amex
Try It Now
All four decline scenarios — plus success, 3DS challenge, and network timeout — are available on the MockCard test cards page. Click any card to open the simulator pre-loaded with that brand and scenario, or use the API playground to fire live requests and inspect the full JSON response.
Free tier gives you 500 card generations per month — more than enough to cover your entire test suite.