All posts
paymentswebhookstestingbackendindie-hackerstripemockcard

The 7 Payment Failure Modes That Break Most Apps (And How to Test Them Before They Hit Production)

April 18, 202610 min read

It was 2am on Black Friday when my phone started buzzing.

Orders were coming in — great. But so were the support tickets. "I was charged but got no confirmation." "My order says pending." "I got two receipts for one purchase." By the time I figured out what was happening, we had 23 orders in a state I'd call "Schrödinger's payment" — captured on the gateway side, but either stuck, duplicated, or silently swallowed on ours.

The worst part? Every one of those failures was entirely testable. I just hadn't written the tests.

I've shipped payment integrations for fintech startups, e-commerce platforms, and SaaS products. The pattern is always the same: developers test the happy path, maybe test a card decline or two, and call it done. Then production finds the edge cases they didn't, usually at the worst possible moment.

This post is the list I wish I'd had. Seven failure modes that break real apps, and how to test each one before they reach your customers.


Why Most Payment Tests Miss the Real Problems

Here's what most test suites look like for payment flows:

it("charges the card successfully", async () => {
  const result = await chargeCard({ amount: 9900, scenario: "success" });
  expect(result.status).toBe("success");
});

it("handles a declined card", async () => {
  const result = await chargeCard({ amount: 9900, scenario: "declined" });
  expect(result.status).toBe("declined");
});

That's it. Two tests. Ship it.

The problem isn't that these tests are wrong. It's that payment failures in production rarely come from the charge itself. They come from what happens after the charge — the webhook delivery, the timing, the retries, the edge cases in your state machine. None of that shows up in "charge succeeded" and "charge declined" tests.

The seven scenarios below are the ones that actually wake you up at night. Every single one of them is reproducible in a test environment if you use the right tooling.


1. Duplicate Webhooks (Race Condition)

The real-world impact: Your webhook handler fulfils the order. Then the gateway retries because your handler took 3 seconds to respond and the retry timeout is 2 seconds. Now the same order is fulfilled twice. You've shipped two items. Or charged twice. Or sent two confirmation emails to the customer who is now very confused.

Most developers' first attempt looks like this:

app.post("/webhook", async (req, res) => {
  const event = JSON.parse(req.body);

  if (event.type === "payment_intent.succeeded") {
    await fulfillOrder(event.data.object.id); // 💥 no idempotency check
  }

  res.status(200).send("ok");
});

This runs fine in development where you send one webhook at a time. Production sends two.

How to test it with MockCard:

const res = await fetch("https://www.mockcard.io/api/v1/generate", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
  body: JSON.stringify({
    brand:         "visa",
    scenario:      "success",
    amount:        9900,
    currency:      "inr",
    webhook_url:   "https://yourapp.com/webhook",
    simulate_race: true,  // delivers identical webhook ~1s apart, same event ID
  }),
});

Set simulate_race: true and MockCard fires the same webhook twice in quick succession. Your handler either passes or you find the bug now instead of on a Friday night.

The fix is an idempotency table — store the event ID on first processing, check before processing again:

const already = await db.processedEvents.findUnique({ where: { id: event.id } });
if (already) return res.status(200).send("ok"); // already handled

await db.$transaction([
  fulfillOrder(event.data.object.id),
  db.processedEvents.create({ data: { id: event.id } }),
]);

[Screenshot: MockCard Chaos Simulator — simulate_race toggle enabled, showing two identical webhook deliveries in the delivery log]


2. Latency / Delayed Webhooks

The real-world impact: The payment goes through at 2pm. The webhook arrives at 2:47pm. In between, the customer has refreshed the order page 12 times, emailed support twice, and is now on the phone asking why they were charged but got nothing.

Your order status page shows "Pending". Because technically, it is. Your system hasn't received confirmation yet. This is correct behaviour — but it's completely invisible to the customer and to your support team.

I've seen this cause entire refund queues to back up because nobody built a "this is normal, it takes a few minutes" state into the UI.

How to test it with MockCard:

const res = await fetch("https://www.mockcard.io/api/v1/generate", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
  body: JSON.stringify({
    brand:       "visa",
    scenario:    "latency",   // webhook delayed ~9 seconds
    amount:      9900,
    currency:    "inr",
    webhook_url: "https://yourapp.com/webhook",
  }),
});

The charge succeeds immediately. The webhook takes its time. Now test what your order status page shows in that gap. Does it say "Processing — your order is confirmed, we're just waiting for final confirmation"? Or does it say something that will generate a support ticket?

[Screenshot: MockCard order status in the PENDING_WEBHOOK state, showing the "payment received, confirming" message]


3. 3DS Abandonment

The real-world impact: User hits checkout, gets redirected to 3DS OTP page, and closes the tab. Their bank never completes the auth. Your app never hears from the gateway. The order sits in PENDING_PAYMENT until something cleans it up — which, if you haven't built that thing, is never.

Multiply this by your daily checkout volume and you have a growing graveyard of zombie orders that silently inflate your "orders today" metric.

// The common mistake: no expiry logic
const order = await db.orders.create({ status: "PENDING_PAYMENT" });
const { redirect_url } = await initiatePayment(order.id);
// Redirect user... and hope they come back
// If they don't, this order exists forever

How to test it with MockCard:

const res = await fetch("https://www.mockcard.io/api/v1/generate", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
  body: JSON.stringify({
    brand:    "visa",
    scenario: "3ds_abandoned",
    amount:   9900,
    currency: "inr",
  }),
});

// webhook_event.type === "payment_intent.canceled"
// Test that your cron job catches and cancels PENDING_PAYMENT orders older than 30 minutes

The fix is a cron job that runs every 15 minutes and moves stale PENDING_PAYMENT orders to CANCELLED. Test that cron job. MockCard makes it easy to create the exact order state that triggers it.

[Screenshot: MockCard 3DS abandoned scenario — webhook payload showing payment_intent.canceled]


4. Limbo / Pending Forever State

The real-world impact: Payment captured. Webhook never arrives. The customer is charged. Your system has no idea. The order stays pending indefinitely. This is somehow worse than a declined payment — the money moved, but fulfilment didn't.

This happens more than people think. Gateway outages, Cloudflare blips, deployment restarts with no graceful shutdown — all of these can swallow a webhook delivery silently.

// Without reconciliation, this order just... stays here
const order = await db.orders.findOne({ status: "PENDING_WEBHOOK" });
// It was created 6 hours ago. Webhook never came.
// Customer has emailed 3 times.

How to test it with MockCard:

const res = await fetch("https://www.mockcard.io/api/v1/generate", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
  body: JSON.stringify({
    brand:       "visa",
    scenario:    "limbo",   // charge succeeds, webhook never fires
    amount:      9900,
    currency:    "inr",
    webhook_url: "https://yourapp.com/webhook",
  }),
});
// data.status === "success" — but your webhook handler never gets called

The test you need to write isn't just "does the order move to FULFILLED when the webhook arrives" — it's "what happens to orders that stay in PENDING_WEBHOOK for more than an hour." Do you have a reconciliation job? An admin alert? MockCard's limbo scenario lets you reliably create that state and verify your recovery path.

[Screenshot: MockCard limbo scenario — charge success response, empty webhook delivery log]


5. Out-of-Order Events

The real-world impact: A payment_intent.payment_failed event arrives before payment_intent.created. Or a charge.refunded comes in before charge.succeeded. These sequences are theoretically impossible — but network reordering and gateway retry logic make them happen.

If your state machine only moves forward (PENDING → FULFILLED), an out-of-order failure event after a success can reset a fulfilled order to failed. Or vice versa.

// Brittle state machine
if (event.type === "payment_intent.payment_failed") {
  await db.orders.update({ status: "FAILED" }); // what if this order is already FULFILLED?
}

The fix is to filter by current status:

await db.orders.updateMany({
  where: { paymentIntentId, status: "PENDING_WEBHOOK" },  // only move from expected state
  data: { status: "FAILED" },
});

Test this by sending a simulated payment_intent.payment_failed event to your webhook handler for an order that's already FULFILLED, and assert the status doesn't regress.

[Screenshot: MockCard webhook payload for payment_intent.payment_failed — copy the payload to replay against your handler]


6. Declines That Still Trigger Partial Webhooks

The real-world impact: A card is declined for insufficient funds. You show the user an error. But the gateway still fired a payment_intent.payment_failed webhook — and your webhook handler doesn't handle failure events, only success events. So the webhook bounces with a 500, the gateway retries three times, and now your error logs are full of noise and your webhook endpoint has a 40% success rate.

This one doesn't break orders — it breaks your observability.

How to test it with MockCard:

const res = await fetch("https://www.mockcard.io/api/v1/generate", {
  method: "POST",
  headers: { "Authorization": `Bearer ${process.env.MOCKCARD_API_KEY}` },
  body: JSON.stringify({
    brand:       "visa",
    scenario:    "insufficient_funds",
    amount:      9900,
    currency:    "inr",
    webhook_url: "https://yourapp.com/webhook",
  }),
});

// Webhook fires with type: "payment_intent.payment_failed"
// Does your handler return 200 for this event type, or does it 500?

Your webhook handler should return 200 for every event type it receives, even ones it doesn't act on. An unhandled event type should log and return 200 — not throw.

[Screenshot: MockCard decline scenario with webhook delivery — showing payment_intent.payment_failed event payload]


7. Idempotency Failures

The real-world impact: This is the quiet one. It doesn't show up in logs. It doesn't generate support tickets immediately. It shows up three weeks later when you're trying to reconcile revenue and you have 12 duplicate fulfilments you can't explain.

Idempotency failures happen when the same operation can run more than once with different outcomes. A webhook that creates a subscription is processed twice because the second delivery arrived before the first transaction committed. A retry on your own API call creates a second order because the first request timed out but actually succeeded.

// The classic idempotency bug
app.post("/create-order", async (req, res) => {
  const order = await db.orders.create({ ...req.body }); // creates a new order every time
  res.json(order);
  // Client retries on timeout → two orders for one intent
});

The fix at the application level: accept an idempotency key from the client, store it, and return the original response on duplicate requests. The fix at the webhook level: the idempotency table from scenario 1.

Test idempotency by replaying the same event ID to your webhook handler twice and asserting the order state is identical after both calls.

[Screenshot: MockCard simulate_race mode — two deliveries of evt_mock_abc123, same payload, same signature]


Ship It, Sleep Through the Night

The reason I still think about that Black Friday is not the 23 stuck orders. It's that I had the tools to prevent all of it. I just hadn't used them.

None of these failure modes are exotic. Every payment integration will encounter duplicate webhooks, delayed deliveries, abandoned 3DS flows, and eventually a limbo state that makes no sense until you dig into the gateway logs. The developers who sleep through production incidents are the ones who've already seen these failures in test — and built the recovery paths before they needed them.

You don't need a load test environment or a gateway sandbox account to test this. Every scenario above is reproducible with a single API call.


Want to test these exact failure modes without risking real money or waiting for gateway approval? MockCard's Playground lets you fire any scenario from your browser — free for the first 10 generations, no signup required. For the chaos scenarios (race condition, limbo, latency), the Pro plan unlocks them all.

The 2am phone call is optional. The tests aren't.