Black Friday. 11:47 AM. A Next.js Commerce store running on Vercel, Stripe webhooks landing cleanly, orders processing. The team is watching the dashboard. Revenue is climbing.
Then a Slack message from customer support: "Someone says they got charged twice for the same order."
The dev who built the checkout opens the webhook logs. Everything looks fine — payment_intent.succeeded, 200s, no errors. But the order fulfillment table has two rows for the same paymentIntentId. How?
The webhook had arrived. Stripe's HTTP client waited 8 seconds for a response. The Vercel function, briefly under load, took 9 seconds to respond. Stripe marked it as failed and retried. The second delivery landed 30 seconds later when the function was fast again — and processed the order a second time, shipping two units and charging the customer once.
No test had caught it because no test had ever fired a real webhook at the handler twice.
What Your Current Tests Are Actually Testing
Most Next.js payment test suites look like this:
// ❌ this test isn't testing what you think it is
it('processes payment', async () => {
const mockStripe = { paymentIntents: { create: jest.fn().mockResolvedValue({ id: 'pi_123', status: 'succeeded' }) } };
const result = await processPayment({ amount: 9900, stripe: mockStripe });
expect(result.status).toBe('succeeded');
});
You're testing that your code calls the Stripe mock correctly. You are not testing your webhook handler, your database write, your idempotency logic, or any of the failure modes that actually matter.
MockCard gives you a real API that returns deterministic responses and fires real signed webhooks at your actual handler code. No mocks. No Stripe test keys. No sandbox dashboard.
Setup
npm install --save-dev vitest @vitest/coverage-v8
// lib/mockcard.ts
// MockCard client — wraps the generate API for use in tests
const BASE = 'https://mockcard.io';
interface GenerateOptions {
brand?: 'visa' | 'mastercard' | 'rupay' | 'amex';
scenario: string;
gateway?: 'stripe' | 'razorpay';
amount?: number;
currency?: string;
webhook_url?: string;
simulate_race?: boolean;
}
export async function generate(opts: GenerateOptions) {
const res = await fetch(`${BASE}/api/v1/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.MOCKCARD_API_KEY!,
},
body: JSON.stringify({ brand: 'visa', amount: 9900, currency: 'inr', ...opts }),
});
if (!res.ok && res.status !== 402 && res.status !== 504) {
throw new Error(`MockCard API error: ${res.status}`);
}
return res.json();
}
The Webhook Handler — App Router Style
Before writing tests you need code worth testing. Here's a production-grade Next.js webhook route:
// app/api/webhook/stripe/route.ts
import { createHmac, timingSafeEqual } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // always verify the RAW body, never parse first
const signature = req.headers.get('x-mockcard-signature') ?? '';
// MockCard uses the same HMAC-SHA256 format as real Stripe webhooks
// so your production and test signature logic is identical
const expected = 'sha256=' + createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (sigBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(sigBuffer, expectedBuffer)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const event = JSON.parse(rawBody);
// Idempotency — gateways retry, your handler must not process twice
const seen = await db.webhookEvent.findUnique({ where: { id: event.id } });
if (seen) return NextResponse.json({ received: true });
if (event.type === 'payment_intent.succeeded') {
await db.order.update({
where: { paymentIntentId: event.data.object.id },
data: { status: 'FULFILLED' },
});
}
if (event.type === 'payment_intent.canceled') {
await db.order.update({
where: { paymentIntentId: event.data.object.id },
data: { status: 'CANCELLED' },
});
}
await db.webhookEvent.create({ data: { id: event.id, processedAt: new Date() } });
return NextResponse.json({ received: true });
}
Testing the Standard Scenarios
// tests/payment-scenarios.test.ts
import { describe, it, expect } from 'vitest';
import { generate } from '@/lib/mockcard';
describe('standard payment scenarios', () => {
it('success — response includes transaction reference to store', async () => {
// MockCard returns a deterministic success with a real Luhn-valid card
const data = await generate({ scenario: 'success' });
expect(data.status).toBe('success');
// payment_intent_id is what you store in your DB
// It matches event.data.object.id when the webhook fires
expect(data.payment_intent_id).toMatch(/^pi_mock_/);
expect(data.card.luhn_valid).toBe(true);
// Your checkout should create an order in "pending_webhook" status
// NOT "fulfilled" — fulfilment happens when the webhook arrives
const order = await createOrder({ paymentIntentId: data.payment_intent_id });
expect(order.status).toBe('PENDING_WEBHOOK');
});
it.each([
['insufficient_funds', 'card_declined', 'insufficient_funds'],
['do_not_honor', 'card_declined', 'do_not_honor'],
['expired_card', 'expired_card', 'expired_card'],
['incorrect_cvv', 'incorrect_cvc', 'incorrect_cvc'],
])('%s — correct error code surfaces to UI', async (scenario, code, decline) => {
// MockCard returns the exact error shape your frontend error handler reads
const data = await generate({ scenario });
expect(data.error.code).toBe(code);
expect(data.error.decline_code).toBe(decline);
expect(data.error.message.length).toBeGreaterThan(0);
// If your UI reads error.decline_code to pick the right message,
// this test confirms every decline maps to a human-readable string
});
it('3ds_challenge — next_action present, not a final decline', async () => {
// MockCard returns the requires_action shape so you can test
// your redirect logic without touching a real browser
const data = await generate({ scenario: '3ds_challenge' });
expect(data.error.code).toBe('authentication_required');
expect(data.error.next_action.type).toBe('redirect_to_url');
expect(data.error.next_action.redirect_to_url.url).toContain('/3ds/challenge');
// Your checkout must redirect, not show a "payment failed" message
const action = resolveNextAction(data.error);
expect(action.type).toBe('redirect');
});
it('network_timeout — 504, retry logic triggered', async () => {
// MockCard waits 10 s then returns 504 — same as a real gateway timeout
// Your checkout should catch this and offer a retry, not a blank error
const start = Date.now();
const data = await generate({ scenario: 'network_timeout' });
expect(data).toHaveProperty('error');
expect(data.error.code).toBe('gateway_timeout');
expect(Date.now() - start).toBeGreaterThan(9000); // confirms the lag is real
});
});
Testing the Webhook Handler Directly
// tests/webhook-handler.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createHmac } from 'crypto';
import { POST } from '@/app/api/webhook/stripe/route';
import { NextRequest } from 'next/server';
const WEBHOOK_SECRET = 'test_secret';
function makeWebhookRequest(event: object) {
const body = JSON.stringify(event);
const sig = 'sha256=' + createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex');
return new NextRequest('http://localhost/api/webhook/stripe', {
method: 'POST',
body,
headers: { 'x-mockcard-signature': sig },
});
}
describe('webhook handler', () => {
it('rejects tampered signatures', async () => {
const req = new NextRequest('http://localhost/api/webhook/stripe', {
method: 'POST',
body: '{"id":"evt_1","type":"payment_intent.succeeded"}',
headers: { 'x-mockcard-signature': 'sha256=fakesignature' },
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it('is idempotent — duplicate event id processed only once', async () => {
const event = {
id: 'evt_already_seen',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_mock_abc123' } },
};
// First delivery
await POST(makeWebhookRequest(event));
// Second delivery — same event id (simulate_race scenario does exactly this)
await POST(makeWebhookRequest(event));
// Order fulfilled exactly once
const count = await db.order.count({
where: { paymentIntentId: 'pi_mock_abc123', status: 'FULFILLED' },
});
expect(count).toBe(1); // not 2
});
});
The Chaos Suite
Standard scenarios test what your code does right. Chaos scenarios test what it does when the infrastructure around it behaves badly. These four scenarios are responsible for the majority of production payment bugs that teams discover after launch.
1. Duplicate Webhook (simulate_race) — Pro
Every payment gateway retries webhook delivery when your server times out. Both requests carry the same event.id. If your handler doesn't deduplicate, one payment becomes two fulfilled orders.
// tests/chaos/duplicate-webhook.test.ts
import { describe, it, expect } from 'vitest';
import { generate } from '@/lib/mockcard';
describe('chaos: duplicate webhook', () => {
it('order is fulfilled exactly once when webhook fires twice', async () => {
const fulfillmentCalls: string[] = [];
// Mock your fulfilment function — we're counting calls
vi.spyOn(orderService, 'fulfill').mockImplementation(async (paymentId) => {
fulfillmentCalls.push(paymentId);
});
// MockCard fires two webhook deliveries with identical event ids ~1 s apart
const data = await generate({
scenario: 'success',
simulate_race: true, // Pro: duplicate delivery, same event.id
webhook_url: webhookServer.url,
});
await vi.waitFor(() => webhookServer.receivedCount >= 2, { timeout: 5000 });
const paymentId = data.payment_intent_id;
// This is the test that catches the Black Friday double-ship bug
expect(fulfillmentCalls.filter(id => id === paymentId).length).toBe(1);
// If this fails: add an idempotency check on event.id before calling fulfill()
});
});
2. 3DS Abandonment (3ds_abandoned) — Pro
User opens the 3DS modal, sees the OTP screen, closes the tab. payment_intent.canceled fires immediately. Most Next.js checkouts leave the order in pending because they only handle succeeded and payment_failed. Inventory never releases. The order sits forever.
// tests/chaos/3ds-abandoned.test.ts
describe('chaos: 3DS abandonment', () => {
it('order is cancelled — not left pending — when 3DS modal is closed', async () => {
// MockCard fires payment_intent.canceled immediately
// No browser interaction, no manual test, no "open incognito and close the tab"
const data = await generate({ scenario: '3ds_abandoned' }); // Pro
expect(data.error.code).toBe('payment_intent_authentication_failure');
const webhookEvent = data.webhook_event;
expect(webhookEvent.type).toBe('payment_intent.canceled');
const paymentId = webhookEvent.data.object.id;
await db.order.create({ data: { paymentIntentId: paymentId, status: 'PENDING_3DS' } });
// Simulate your webhook handler receiving payment_intent.canceled
await handleWebhookEvent(webhookEvent);
const order = await db.order.findUnique({ where: { paymentIntentId: paymentId } });
expect(order?.status).toBe('CANCELLED');
// Not 'PENDING_3DS' — that means your handler has no case for canceled
// and your inventory is now permanently allocated to a ghost order
});
});
3. The Limbo State (limbo) — Pro
Payment is captured. Your API returns 201. The customer is charged. But every webhook delivery attempt fails — your endpoint was cold, behind a firewall, or returned 500 three times. The order never fulfils. The customer emails support.
// tests/chaos/limbo.test.ts
describe('chaos: limbo state', () => {
it('reconciliation job catches payment captured with no webhook', async () => {
// MockCard returns 201 (payment "captured") but intentionally delivers no webhook
// This simulates your endpoint being unreachable during all 3 delivery attempts
const data = await generate({
scenario: 'limbo', // Pro: captured payment, silent webhook
webhook_url: webhookServer.url,
});
expect(data.status).toBe('success'); // payment succeeded
const paymentId = data.payment_intent_id;
await db.order.create({
data: { paymentIntentId: paymentId, status: 'PENDING_WEBHOOK' },
});
// Confirm no webhook arrived
await new Promise(r => setTimeout(r, 3000));
expect(webhookServer.receivedCount).toBe(0);
// Run your reconciliation job — the one that finds captured payments
// with no corresponding fulfilled order and re-queues or flags them
await reconcileCapturedPayments();
const order = await db.order.findUnique({ where: { paymentIntentId: paymentId } });
// If this fails, you have no recovery path for the limbo state
// That means: customer charged, order never fulfilled, support ticket in 6 hours
expect(['FULFILLED', 'FLAGGED_FOR_REVIEW']).toContain(order?.status);
});
});
4. Latency Storm (latency) — Pro
Webhook delivery is delayed 9 seconds. Your Vercel function timeout is 10 seconds. The delivery times out, MockCard retries, and now your handler runs twice for the same payment — once from each retry. Your idempotency check on event.id protects you, but only if it exists.
// tests/chaos/latency.test.ts
describe('chaos: latency storm', () => {
it('delayed webhook processed exactly once despite retry', async () => {
const fulfillCount = new Map<string, number>();
webhookServer.onEvent(async (event) => {
// MockCard tests whether you have this check
// Without it, the retry causes a second fulfillment
if (await db.webhookEvent.findUnique({ where: { id: event.id } })) return;
const paymentId = event.data.object.id;
fulfillCount.set(paymentId, (fulfillCount.get(paymentId) ?? 0) + 1);
await db.webhookEvent.create({ data: { id: event.id, processedAt: new Date() } });
});
// MockCard returns 201 immediately, delivers webhook 9 s later
// If your server times out at 10 s, MockCard retries — handler runs twice
const data = await generate({
scenario: 'latency', // Pro: 9 s delivery delay
webhook_url: webhookServer.url,
});
const paymentId = data.payment_intent_id;
await vi.waitFor(
() => fulfillCount.has(paymentId),
{ timeout: 20_000 }
);
expect(fulfillCount.get(paymentId)).toBe(1);
// If this is 2: your handler has no idempotency check
// The latency scenario exposes this before Stripe does
});
});
Razorpay in the Same Test Suite
If your Next.js app integrates Razorpay, the response format is completely different. MockCard switches the entire wire format with one field — your tests can cover both gateways without two separate sandbox accounts.
// tests/razorpay-scenarios.test.ts
import { createHmac } from 'crypto';
describe('razorpay gateway simulation', () => {
it('success response uses Razorpay payment entity format', async () => {
// MockCard returns payment.captured with Razorpay's exact payload shape
const data = await generate({ scenario: 'success', gateway: 'razorpay' });
expect(data.payment_intent_id).toMatch(/^pay_mock_/); // Razorpay prefix
expect(data.webhook_event.event).toBe('payment.captured'); // not type, not succeeded
expect(data.webhook_event.payload.payment.entity.status).toBe('captured');
expect(data.webhook_event.payload.payment.entity.card.network).toBe('Visa');
});
it('decline uses Razorpay error envelope, not Stripe', async () => {
// The error format is completely different — if your error handler parses
// error.decline_code on a Razorpay response, it silently returns undefined
const data = await generate({ scenario: 'insufficient_funds', gateway: 'razorpay' });
const error = data.error;
expect(error.code).toBe('BAD_REQUEST_ERROR'); // not "card_declined"
expect(error.reason).toBe('insufficient_balance'); // not "insufficient_funds"
expect(error.source).toBe('customer');
expect(error.metadata.payment_id).toMatch(/^pay_mock_/);
});
it('Razorpay webhook uses X-Razorpay-Signature with plain hex (no sha256= prefix)', async () => {
// MockCard fires the webhook with Razorpay's exact header format
// Plain HMAC-SHA256 hex — no "sha256=" prefix unlike Stripe
const data = await generate({
scenario: 'success',
gateway: 'razorpay',
webhook_url: webhookServer.url,
});
await vi.waitFor(() => webhookServer.receivedCount > 0, { timeout: 5000 });
const received = webhookServer.lastRequest;
const body = received.body;
const sig = received.headers['x-razorpay-signature'];
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(body)
.digest('hex'); // NO "sha256=" prefix
expect(sig).toBe(expected);
// If your Razorpay handler checks for "sha256=" prefix it will reject every webhook
});
});
CI Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
testTimeout: 30_000, // chaos scenarios take up to 20 s
setupFiles: ['./tests/setup.ts'],
},
});
# .github/workflows/payment-tests.yml
name: Payment Integration Tests
on: [push, pull_request]
jobs:
payment-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx vitest run tests/payment-scenarios.test.ts tests/chaos/
env:
MOCKCARD_API_KEY: ${{ secrets.MOCKCARD_API_KEY }}
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
No Stripe test keys. No Razorpay sandbox. No webhook relay tunnels. The same tests run identically on your laptop and in CI.
What You Just Built
You have a test suite that:
- Confirms every decline surfaces the right error code to your UI
- Verifies your webhook signature before the handler touches any data
- Proves your idempotency check actually works when the same event fires twice
- Catches 3DS ghost orders before they hold your inventory permanently
- Finds the limbo-state gap before a customer emails you about a missing order
- Tests your Razorpay integration with the exact same suite
Every one of these tests exercises real handler code against real signed requests. No mocks, no Stripe test dashboard, no tunneling ngrok into localhost at 2AM trying to reproduce a bug that only happens under load.
The chaos scenarios exist because production payment infrastructure behaves badly in exactly these ways. The question is whether you find it first — or your customers do.
Run Your First Test in 5 Minutes
MockCard is free to start. The standard scenarios — success, every decline, 3DS, network timeout — work immediately with no configuration. Paste an API key, run npx vitest, ship with confidence.
The chaos scenarios (simulate_race, 3ds_abandoned, limbo, latency) are Pro. They are also the ones that cause customer support tickets, refunds, and chargebacks. If you ship without testing them, you are hoping those bugs don't exist.
They exist. MockCard just makes sure you find them first.
Get your free API key → — No credit card. No sandbox. No waiting.
Upgrade to Pro → — Run the chaos suite. Stop finding bugs in production.
API reference, schemas, and webhook examples: mockcard.io/docs. Live chaos simulator: mockcard.io/payment-failure-testing.