All posts
testingpaymentswebhooksstriperazorpaynextjspythonmockcard

Bulletproof Your Payment Integration: Testing Every Scenario with MockCard

April 18, 202614 min read

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.

What this covers: All 10 scenarios MockCard supports, multi-language test implementations, webhook verification, idempotency, and the chaos scenarios that only show up under real production load.

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
Common mistake: Showing a generic "Payment failed, please try again" for do_not_honor declines. A retry will fail again and frustrate the user. Show a message that directs them to their bank.

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"]
Critical: Do not mark the order as fulfilled when you receive a 3DS response. The payment has not been captured yet. Wait for a 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
Timeout ≠ Decline: A network timeout does not mean the charge failed. The payment may have been captured on the gateway side. Never mark the order FAILED on a timeout — leave it PENDING and reconcile via webhook.

Free plan covers scenarios 1–7. The scenarios below — 3DS abandonment, limbo state, and race condition — are available on the Pro plan. These are the failure modes that only appear under real production load.

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
The real-world version: Webhook delivery has SLAs but no guarantees. Cloudflare outages, deployment restarts, and load balancer misconfigs all swallow webhooks silently. Build a reconciliation job that checks PENDING_WEBHOOK orders against the gateway API directly.

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
Always verify the raw body — before JSON.parse(), before any middleware transforms the request. Verifying on parsed-then-re-serialised JSON will fail due to key ordering and whitespace differences.

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.


Try every scenario right now — no signup required. The MockCard Playground lets you fire any scenario from your browser and see the full response before writing a single line of code.

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

The chaos scenarios — 3DS abandonment, limbo, and duplicate delivery — are the ones that cause real production incidents. They are available on MockCard Pro along with webhook delivery and 10,000 requests/month.

See Pro plan →    Full API docs →