Most payment bugs don't surface in tests. They surface at 11pm on a Friday when a customer's card is charged twice, or when an order stays stuck "pending" for hours because a webhook was never delivered. By then, the damage is done.
This post covers every failure mode a payment flow encounters in production — and how to write tests that catch all of them before a single real card is charged. Every code sample works against the MockCard API — no gateway sandbox, no test account, no flaky network.
The 10 Scenarios Every Payment Flow Must Handle
Real payment flows fail in exactly these ways. Here is every one of them, with the API call and a test for each.
1. Successful Payment
The baseline. If this doesn't work, nothing else matters.
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}`,
},
body: JSON.stringify({
brand: "visa",
scenario: "success",
amount: 49900, // ₹499 in paise
currency: "inr",
}),
});
const data = await res.json();
// data.status === "success"
// data.payment_intent_id === "pi_mock_xxx"
// data.card.card_number — Luhn-valid Visa number
// data.webhook_event.type === "payment_intent.succeeded"
import httpx, os
res = httpx.post(
"https://www.mockcard.io/api/v1/generate",
headers={"Authorization": f"Bearer {os.environ['MOCKCARD_API_KEY']}"},
json={
"brand": "visa",
"scenario": "success",
"amount": 49900,
"currency": "inr",
},
)
data = res.json()
# data["status"] == "success"
# data["payment_intent_id"] == "pi_mock_xxx"
# data["webhook_event"]["type"] == "payment_intent.succeeded"
body, _ := json.Marshal(map[string]any{
"brand": "visa",
"scenario": "success",
"amount": 49900,
"currency": "inr",
})
req, _ := http.NewRequest("POST", "https://www.mockcard.io/api/v1/generate", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("MOCKCARD_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
var data map[string]any
json.NewDecoder(resp.Body).Decode(&data)
// data["status"] == "success"
// data["payment_intent_id"] == "pi_mock_xxx"
What to assert: status is "success", payment_intent_id starts with pi_mock_, card.luhn_valid is true, webhook_event.type is "payment_intent.succeeded".
2. Insufficient Funds
The most common decline in production. Your UI must show a clear, actionable message — not a raw error code.
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({ brand: "visa", scenario: "insufficient_funds", amount: 49900, currency: "inr" }),
});
// HTTP 402
const data = await res.json();
// data.error.code === "card_declined"
// data.error.decline_code === "insufficient_funds"
// data.error.message === "Your card has insufficient funds."
// data.webhook_event.type === "payment_intent.payment_failed"
// ✅ Test: your UI should render "Your card has insufficient funds." — not "card_declined"
res = httpx.post(
"https://www.mockcard.io/api/v1/generate",
headers={"Authorization": f"Bearer {os.environ['MOCKCARD_API_KEY']}"},
json={"brand": "visa", "scenario": "insufficient_funds", "amount": 49900, "currency": "inr"},
)
assert res.status_code == 402
data = res.json()
assert data["error"]["decline_code"] == "insufficient_funds"
assert "insufficient funds" in data["error"]["message"].lower()
3. Do Not Honor
A generic decline from the issuing bank — often triggered by fraud rules. No amount of retrying helps. The user needs to call their bank or use a different card.
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({ brand: "mastercard", scenario: "do_not_honor", amount: 49900, currency: "inr" }),
});
const data = await res.json();
// data.error.decline_code === "do_not_honor"
// The right UX: "Your card was declined. Please contact your bank or use a different card."
// The wrong UX: "Payment failed. Try again." — retrying won't help
4. Expired Card
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({ brand: "visa", scenario: "expired_card", amount: 49900, currency: "inr" }),
});
// data.error.decline_code === "expired_card"
// UI should: prompt the user to check their card expiry date
5. Incorrect CVV
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({ brand: "visa", scenario: "incorrect_cvv", amount: 49900, currency: "inr" }),
});
// data.error.decline_code === "incorrect_cvv"
// UI should: highlight the CVV field, ask user to recheck the number on the card
6. 3DS Challenge
3D Secure adds a second authentication step — the user is redirected to their bank's page to confirm via OTP or biometric. Your checkout must handle the redirect, wait for completion, and then look up the result via webhook.
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({ brand: "visa", scenario: "3ds_challenge", amount: 49900, currency: "inr" }),
});
// HTTP 402 (pending action — not a final decline)
const data = await res.json();
// data.webhook_event.type === "payment_intent.requires_action"
// data.webhook_event.data.object.next_action.redirect_to_url.url — redirect here
// ✅ Test that your checkout:
// 1. Redirects the user to the 3DS URL
// 2. Has a return_url that handles the post-challenge callback
// 3. Does NOT mark the order FULFILLED at this point — wait for the webhook
res = httpx.post(
"https://www.mockcard.io/api/v1/generate",
headers={"Authorization": f"Bearer {os.environ['MOCKCARD_API_KEY']}"},
json={"brand": "visa", "scenario": "3ds_challenge", "amount": 49900, "currency": "inr"},
)
assert res.status_code == 402
data = res.json()
event = data["webhook_event"]
assert event["type"] == "payment_intent.requires_action"
next_action = event["data"]["object"]["next_action"]
assert "redirect_to_url" in next_action
# Your app should redirect to next_action["redirect_to_url"]["url"]
payment_intent.succeeded webhook after the challenge completes.
7. Network Timeout
The gateway takes 10 seconds and returns a 504. This tests whether your checkout handles a hung request gracefully — with a timeout, a user-friendly message, and without leaving a ghost order in the database.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12_000);
try {
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({ brand: "visa", scenario: "network_timeout", amount: 49900, currency: "inr" }),
});
// MockCard returns HTTP 504 after ~10 seconds
// data.error.decline_code === "network_timeout"
} catch (err) {
// AbortError if your own timeout fires first
} finally {
clearTimeout(timeout);
}
// ✅ Test that:
// 1. Your order status stays PENDING_PAYMENT (not FAILED) — the charge may still go through
// 2. You show "Payment timed out. Please try again." — not a blank screen
// 3. You have a cron job that expires stuck PENDING_PAYMENT orders after 30 minutes
See Pro plan →
Chaos Scenarios: The Failures Nobody Tests
These are the failures that hit production systems and are almost never covered by test suites. They don't happen on every request — they happen under load, under retry, under race conditions. MockCard Pro simulates all of them deterministically.
8. 3DS Abandoned
The user is redirected to the 3DS challenge page and closes the browser. Your return URL never fires. The order sits in PENDING_PAYMENT indefinitely.
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({ brand: "visa", scenario: "3ds_abandoned", amount: 49900, currency: "inr" }),
});
// HTTP 402
// data.webhook_event.type === "payment_intent.canceled"
// ✅ Test that:
// 1. Orders stuck in PENDING_PAYMENT for >30 min are expired by your cron job
// 2. The order page shows "This order has expired" — not a blank or broken state
// 3. The user can start a fresh checkout (new order, not a retry on the same order ID)
Cron job to expire stuck orders (Next.js / Vercel):
// app/api/cron/expire-orders/route.ts
// vercel.json: { "crons": [{ "path": "/api/cron/expire-orders", "schedule": "*/15 * * * *" }] }
export async function GET(req: NextRequest) {
if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const cutoff = new Date(Date.now() - 30 * 60 * 1000);
const { count } = await prisma.order.updateMany({
where: { status: "PENDING_PAYMENT", createdAt: { lt: cutoff } },
data: { status: "CANCELLED" },
});
return NextResponse.json({ cancelled: count });
}
9. Limbo State
The payment is captured but the webhook is never delivered. The order stays in PENDING_WEBHOOK forever. The customer is charged but sees no confirmation.
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({
brand: "visa",
scenario: "limbo",
amount: 49900,
currency: "inr",
webhook_url: "https://yourapp.com/api/webhook/stripe",
}),
});
// HTTP 201 — charge succeeded, but webhook is NEVER delivered
// data.status === "success"
// ✅ Test that:
// 1. Orders in PENDING_WEBHOOK for >1 hour are flagged for reconciliation
// 2. Your order status page shows "Payment received, confirming order..." — not a blank state
// 3. You have an admin view or alert for lingering PENDING_WEBHOOK orders
10. Race Condition — Duplicate Webhook
The webhook fires twice for the same payment — a gateway retry because your handler was slow to respond. Without idempotency, the order gets fulfilled twice.
// simulate_race: true delivers identical webhooks ~1s apart with the same event ID (Pro)
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
body: JSON.stringify({
brand: "visa",
scenario: "success",
amount: 49900,
currency: "inr",
webhook_url: "https://yourapp.com/api/webhook/stripe",
simulate_race: true,
}),
});
// ✅ Test that your webhook handler is idempotent:
// 1. First delivery → order moves to FULFILLED
// 2. Second delivery (same event.id) → no-op, order stays FULFILLED
res = httpx.post(
"https://www.mockcard.io/api/v1/generate",
headers={"Authorization": f"Bearer {os.environ['MOCKCARD_API_KEY']}"},
json={
"brand": "visa",
"scenario": "success",
"amount": 49900,
"currency": "inr",
"webhook_url": "https://yourapp.com/api/webhook/stripe",
"simulate_race": True,
},
)
# Two identical webhooks arrive ~1s apart — deduplicate on event["id"]
The idempotency pattern:
export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get("x-mockcard-signature") ?? "";
// 1. Verify signature (always on raw body, before parse)
const expected = "sha256=" + createHmac("sha256", process.env.MOCKCARD_WEBHOOK_SECRET!)
.update(payload).digest("hex");
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(payload);
// 2. Deduplicate — return 200 immediately if we've seen this event
const seen = await prisma.processedEvent.findUnique({ where: { id: event.id } });
if (seen) return NextResponse.json({ received: true });
// 3. Process + record atomically in one transaction
if (event.type === "payment_intent.succeeded") {
await prisma.$transaction([
prisma.order.updateMany({
where: { paymentIntentId: event.data?.object?.id, status: "PENDING_WEBHOOK" },
data: { status: "FULFILLED" },
}),
prisma.processedEvent.create({ data: { id: event.id } }),
]);
}
return NextResponse.json({ received: true });
}
@app.post("/webhook/stripe")
async def webhook(request: Request):
raw_body = await request.body()
signature = request.headers.get("x-mockcard-signature", "")
expected = "sha256=" + hmac.new(
os.environ["MOCKCARD_WEBHOOK_SECRET"].encode(), raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
# Deduplicate
if db.query(ProcessedEvent).filter_by(id=event["id"]).first():
return {"received": True}
payment_intent_id = event["data"]["object"]["id"]
with db.begin():
if event["type"] == "payment_intent.succeeded":
db.query(Order).filter_by(
payment_intent_id=payment_intent_id, status="PENDING_WEBHOOK"
).update({"status": "FULFILLED"})
db.add(ProcessedEvent(id=event["id"]))
return {"received": True}
-- Store processed event IDs to prevent double-fulfilment
CREATE TABLE processed_events (
id TEXT PRIMARY KEY, -- event.id from the webhook payload
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Optional: purge after 30 days
CREATE INDEX idx_processed_events_created ON processed_events (created_at);
Webhook Signature Verification
Every webhook from MockCard is signed. Verify the signature before processing — this is your defence against spoofed events that trigger order fulfilment without a real payment.
import { createHmac, timingSafeEqual } from "crypto";
function verifySignature(rawBody: string | Buffer, signature: string, secret: string): boolean {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
try {
return timingSafeEqual(Buffer.from(signature, "utf8"), Buffer.from(expected, "utf8"));
} catch {
return false;
}
}
import hmac, hashlib
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
)
func VerifySignature(rawBody []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(signature), []byte(expected)) == 1
}
require "openssl"
def verify_signature(raw_body, signature, secret)
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
Gateway Simulation: Stripe and Razorpay
Pass "gateway": "razorpay" and the entire response — error shape, webhook payload, and signature header — matches Razorpay's exact wire format.
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${MOCKCARD_API_KEY}` },
body: JSON.stringify({
brand: "visa", scenario: "insufficient_funds", amount: 49900, currency: "inr",
gateway: "stripe",
}),
});
// Error shape:
// { "error": { "code": "card_declined", "message": "...", "decline_code": "insufficient_funds" } }
// Webhook header: X-MockCard-Signature: sha256=<hex>
// Payment ID: pi_mock_xxx
const res = await fetch("https://www.mockcard.io/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${MOCKCARD_API_KEY}` },
body: JSON.stringify({
brand: "visa", scenario: "insufficient_funds", amount: 49900, currency: "inr",
gateway: "razorpay",
}),
});
// Error shape:
// { "error": { "code": "BAD_REQUEST_ERROR", "description": "...", "reason": "insufficient_balance",
// "source": "customer", "step": "payment_authorization" } }
// Webhook header: X-Razorpay-Signature: <hex> (no "sha256=" prefix)
// Payment ID: pay_mock_xxx
Complete Test Matrix
Here's a test file that covers all 7 standard scenarios in one pass:
// payment.test.ts
import { describe, it, expect } from "vitest";
const BASE_URL = "https://www.mockcard.io/api/v1/generate";
const HEADERS = {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}`,
};
const SCENARIOS = [
{ scenario: "success", expectedStatus: 201, expectedType: "payment_intent.succeeded" },
{ scenario: "insufficient_funds", expectedStatus: 402, expectedType: "payment_intent.payment_failed" },
{ scenario: "do_not_honor", expectedStatus: 402, expectedType: "payment_intent.payment_failed" },
{ scenario: "expired_card", expectedStatus: 402, expectedType: "payment_intent.payment_failed" },
{ scenario: "incorrect_cvv", expectedStatus: 402, expectedType: "payment_intent.payment_failed" },
{ scenario: "3ds_challenge", expectedStatus: 402, expectedType: "payment_intent.requires_action" },
{ scenario: "network_timeout", expectedStatus: 504, expectedType: "payment_intent.payment_failed" },
] as const;
describe("MockCard scenarios", () => {
for (const { scenario, expectedStatus, expectedType } of SCENARIOS) {
it(`handles ${scenario}`, async () => {
const res = await fetch(BASE_URL, {
method: "POST", headers: HEADERS,
body: JSON.stringify({ brand: "visa", scenario, amount: 49900, currency: "inr" }),
});
const data = await res.json();
expect(res.status).toBe(expectedStatus);
expect(data.webhook_event.type).toBe(expectedType);
if (scenario === "success") {
expect(data.payment_intent_id).toMatch(/^pi_mock_/);
expect(data.card.luhn_valid).toBe(true);
} else {
expect(data.error).toBeDefined();
}
});
}
});
Run this in CI: no gateway credentials, no flaky network, completes in under 30 seconds.
Open Playground →
Quick Reference: All Scenarios
| Scenario | HTTP | Event type | Plan |
|---|---|---|---|
success |
201 | payment_intent.succeeded |
Free |
insufficient_funds |
402 | payment_intent.payment_failed |
Free |
do_not_honor |
402 | payment_intent.payment_failed |
Free |
expired_card |
402 | payment_intent.payment_failed |
Free |
incorrect_cvv |
402 | payment_intent.payment_failed |
Free |
3ds_challenge |
402 | payment_intent.requires_action |
Free |
network_timeout |
504 | payment_intent.payment_failed |
Free |
3ds_abandoned |
402 | payment_intent.canceled |
Pro |
limbo |
201 | (webhook never delivered) | Pro |
simulate_race |
201 | payment_intent.succeeded ×2 |
Pro |
See Pro plan → Full API docs →