All posts
payment testingwebhooksNode.jsNext.jsLaravelRailsJavaPHPidempotencychaos testing

I Double-Charged 11 Customers on Black Friday. Here's the Test Suite That Would Have Stopped It.

March 26, 202614 min read

It was 11:47 PM on Black Friday. I was half-asleep, laptop on my chest, when my phone started buzzing.

Stripe alerts. Payment failures. Then — something worse. Duplicate charge complaints.

We had processed 11 double-charges in under 20 minutes. Users had clicked "Pay" on a slow connection, our server took too long to respond, Stripe retried the webhook, and our handler — which I had written at 2 AM three weeks earlier — had no idempotency check whatsoever.

The next 6 hours were a blur of Slack messages, manual refunds, and a Loom video apology to our most affected customers. We lost two of them for good.

I have thought about that night a lot since then. Not with shame, but with this specific frustration: none of our tests would have caught it. We had good test coverage. We had run the Stripe sandbox. We had done a load test. But none of our tooling could simulate a webhook arriving twice for the same payment event — because Stripe's test environment, by design, doesn't do that.

That's the gap I want to talk about today. And I want to walk through exactly how I'd close it, in every major backend framework.


The actual problem with payment testing

Stripe sandbox is great for testing the happy path. Card declined? Use 4000000000000002. 3DS required? Use 4000002500003155. These are genuinely useful.

But production failures are not usually "wrong card number" failures. They are timing failures. Retry failures. Race conditions. Out-of-order events. The kind of stuff that only happens when your server is under load, when Stripe's retry logic kicks in, when a webhook delivery times out and fires again 30 seconds later.

There are four failure classes I have personally encountered (or seen teammates hit) in production:

Race conditions — same webhook event delivered twice, handler processes both, creates duplicate records or charges.

Delayed webhooks — payment succeeded 10 seconds ago, user is staring at a spinner, your backend has not updated yet, they click again.

3DS abandonment — user closes the authentication modal. Your payment_intent.canceled never fires. Order stays "pending" forever.

Limbo states — card declined at network level but charge attempt still recorded on your end. Two systems disagree on what happened.


The setup: MockCard

MockCard generates test card numbers with real webhook simulation. You give it a scenario and a webhook URL, it generates a card and fires a signed webhook to your actual endpoint — not a mock, not a stub, your real running server.

The key flags:

curl -X POST https://mockcard.io/api/v1/generate \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "brand": "visa",
    "scenario": "success",
    "webhook_url": "https://your-server.com/webhooks/stripe",
    "simulate_race": true
  }'
  • scenariosuccess, declined, 3ds_abandoned, limbo, latency
  • simulate_race: true — delivers the same webhook event twice, a few hundred ms apart
  • webhook_url — MockCard POSTs a signed payload here, just like Stripe would
  • log_id in the response — poll /api/v1/delivery-status/{log_id} to confirm delivery

The webhook payload is HMAC-SHA256 signed with your key, so your existing signature verification works unchanged.


Node.js / Express

The handler:

// routes/webhooks.js
const express = require("express");
const crypto = require("crypto");
const { db } = require("../db");

const router = express.Router();

router.post(
  "/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.headers["x-mockcard-signature"] ?? req.headers["stripe-signature"];
    const expected = crypto
      .createHmac("sha256", process.env.WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    if (sig !== `sha256=${expected}`) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(req.body);

    const existing = await db.query(
      "SELECT id FROM processed_events WHERE event_id = $1",
      [event.id]
    );
    if (existing.rows.length > 0) {
      return res.status(200).json({ status: "already_processed" });
    }

    await db.transaction(async (trx) => {
      await trx.query(
        "INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())",
        [event.id]
      );
      await handleEvent(event, trx);
    });

    res.json({ status: "ok" });
  }
);

Test 1 — Race condition via MockCard simulate_race

MockCard delivers the same signed event twice within ~300ms — exactly how Stripe behaves when your server times out and it retries. Without the idempotency check above, both deliveries create an order.

test("handles duplicate webhook delivery without double-processing", async () => {
  // MockCard fires two deliveries of the same event to our running server
  const res = await fetch("https://mockcard.io/api/v1/generate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
    },
    body: JSON.stringify({
      brand: "visa",
      scenario: "success",
      webhook_url: "http://localhost:3001/webhooks/stripe",
      simulate_race: true, // <-- this is the one that would have saved Black Friday
    }),
  });

  const { log_id } = await res.json();

  // Wait for both deliveries to land
  await new Promise((r) => setTimeout(r, 2000));

  // Poll MockCard delivery log to confirm both attempts were made
  const statusRes = await fetch(
    `https://mockcard.io/api/v1/delivery-status/${log_id}`,
    { headers: { "Authorization": `Bearer ${process.env.MOCKCARD_KEY}` } }
  );
  const { attempts } = await statusRes.json();
  expect(attempts).toBe(2); // MockCard did fire twice

  // But our handler should have created only one order
  const orders = await db.query("SELECT * FROM orders");
  expect(orders.rows).toHaveLength(1);
});

Test 2 — 3DS abandonment leaves no zombie orders

test("marks order as abandoned when 3DS is never completed", async () => {
  // MockCard fires a payment_intent.canceled event — user closed the 3DS modal
  await fetch("https://mockcard.io/api/v1/generate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
    },
    body: JSON.stringify({
      scenario: "3ds_abandoned",
      webhook_url: "http://localhost:3001/webhooks/stripe",
    }),
  });

  await new Promise((r) => setTimeout(r, 1000));

  const order = await db.query("SELECT status FROM orders ORDER BY created_at DESC LIMIT 1");

  // If this fails, you have zombie "pending" orders in production right now
  expect(order.rows[0].status).toBe("abandoned");
});

Next.js (App Router)

The handler:

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { db } from "@/lib/db";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const sig = req.headers.get("x-mockcard-signature") ?? req.headers.get("stripe-signature");

  const expected = crypto
    .createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("hex");

  if (sig !== `sha256=${expected}`) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(rawBody);

  const existing = await db.query(
    "SELECT id FROM processed_events WHERE event_id = $1",
    [event.id]
  );
  if (existing.rows.length > 0) {
    return NextResponse.json({ status: "already_processed" });
  }

  await db.transaction(async (trx) => {
    await trx.query("INSERT INTO processed_events (event_id) VALUES ($1)", [event.id]);
    await processPaymentEvent(event, trx);
  });

  return NextResponse.json({ status: "ok" });
}

Test 1 — MockCard fires two deliveries, handler stays idempotent

test("idempotent under concurrent duplicate delivery", async () => {
  // Ask MockCard to deliver the success event twice to our test server
  const mcRes = await fetch("https://mockcard.io/api/v1/generate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
    },
    body: JSON.stringify({
      scenario: "success",
      webhook_url: process.env.TEST_WEBHOOK_URL, // ngrok or similar in CI
      simulate_race: true,
    }),
  });

  const { webhook_event, log_id } = await mcRes.json();

  // Also fire directly against the handler to test the unit path
  const makeReq = () =>
    new Request("http://localhost/api/webhooks/stripe", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-mockcard-signature": webhook_event.signature,
      },
      body: JSON.stringify(webhook_event.payload),
    });

  const [r1, r2] = await Promise.all([POST(makeReq()), POST(makeReq())]);

  expect(r1.status).toBe(200);
  expect(r2.status).toBe(200);
  expect(await db.count("processed_events", { event_id: webhook_event.payload.id })).toBe(1);
});

Test 2 — Limbo state does not leave order pending

test("limbo scenario: order is not left in pending state", async () => {
  // MockCard fires payment_intent.payment_failed with network-level decline
  const mcRes = await fetch("https://mockcard.io/api/v1/generate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
    },
    body: JSON.stringify({
      scenario: "limbo",
      webhook_url: process.env.TEST_WEBHOOK_URL,
    }),
  });

  const { webhook_event } = await mcRes.json();

  const req = new Request("http://localhost/api/webhooks/stripe", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-mockcard-signature": webhook_event.signature,
    },
    body: JSON.stringify(webhook_event.payload),
  });

  await POST(req);

  const order = await db.findLatestOrder();

  // Must not be pending — if it is, the network decline never reached your handler
  expect(order.status).not.toBe("pending");
  expect(["failed", "timed_out", "declined"]).toContain(order.status);
});

Laravel (PHP)

The handler:

<?php
class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        $payload = $request->getContent();
        $sig = $request->header("X-MockCard-Signature")
            ?? $request->header("Stripe-Signature");

        $expected = "sha256=" . hash_hmac(
            "sha256", $payload, config("services.webhook_secret")
        );

        if (!hash_equals($expected, $sig ?? "")) {
            return response()->json(["error" => "Invalid signature"], 401);
        }

        $event = json_decode($payload, true);
        $eventId = $event["id"];

        $alreadyProcessed = DB::table("processed_events")
            ->where("event_id", $eventId)->exists();

        if ($alreadyProcessed) {
            return response()->json(["status" => "already_processed"]);
        }

        DB::transaction(function () use ($event, $eventId) {
            DB::table("processed_events")->insert([
                "event_id" => $eventId, "processed_at" => now(),
            ]);
            $this->processEvent($event);
        });

        return response()->json(["status" => "ok"]);
    }
}

Test 1 — MockCard simulate_race, one order created

public function test_duplicate_delivery_creates_only_one_order(): void
{
    // MockCard will POST the same signed event to our app twice
    $mc = Http::withToken(config("services.mockcard_key"))
        ->post("https://mockcard.io/api/v1/generate", [
            "brand"         => "visa",
            "scenario"      => "success",
            "webhook_url"   => config("services.test_webhook_url"),
            "simulate_race" => true,
        ])->json();

    // Wait for MockCard to deliver both attempts
    sleep(2);

    // MockCard logged both delivery attempts
    $status = Http::withToken(config("services.mockcard_key"))
        ->get("https://mockcard.io/api/v1/delivery-status/{$mc["log_id"]}")
        ->json();

    $this->assertEquals(2, $status["attempts"]);

    // But only one order and one processed event should exist
    $this->assertDatabaseCount("processed_events", 1);
    $this->assertDatabaseCount("orders", 1);
}

Test 2 — Out-of-order events: late failure does not overwrite paid order

public function test_late_failure_event_does_not_downgrade_paid_order(): void
{
    // Step 1: success event arrives first — order becomes "paid"
    $success = Http::withToken(config("services.mockcard_key"))
        ->post("https://mockcard.io/api/v1/generate", [
            "scenario"    => "success",
            "webhook_url" => config("services.test_webhook_url"),
        ])->json("webhook_event");

    $this->postJson("/webhooks/stripe", $success["payload"],
        ["X-MockCard-Signature" => $success["signature"]]
    )->assertStatus(200);

    $this->assertDatabaseHas("orders", ["status" => "paid"]);

    // Step 2: stale declined event arrives late (Stripe retry from earlier attempt)
    // MockCard generates a declined payload — we reuse the same payment_intent id
    $declined = Http::withToken(config("services.mockcard_key"))
        ->post("https://mockcard.io/api/v1/generate", [
            "scenario" => "declined",
        ])->json("webhook_event");

    // Inject the same payment_intent id to simulate out-of-order delivery
    $declined["payload"]["data"]["object"]["id"] =
        $success["payload"]["data"]["object"]["id"];

    $this->postJson("/webhooks/stripe", $declined["payload"],
        ["X-MockCard-Signature" => $declined["signature"]]
    )->assertStatus(200);

    // Order must still be paid — late failure must not overwrite success
    $this->assertDatabaseHas("orders", ["status" => "paid"]);
    $this->assertDatabaseMissing("orders", ["status" => "failed"]);
}

Ruby on Rails

The handler:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_signature

  def stripe
    event    = JSON.parse(request.body.read)
    event_id = event["id"]

    if ProcessedEvent.exists?(event_id: event_id)
      render json: { status: "already_processed" } and return
    end

    ActiveRecord::Base.transaction do
      ProcessedEvent.create!(event_id: event_id)
      process_event(event)
    end

    render json: { status: "ok" }
  end

  private

  def verify_signature
    payload  = request.body.read.tap { request.body.rewind }
    sig      = request.headers["X-MockCard-Signature"] || request.headers["Stripe-Signature"]
    expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("WEBHOOK_SECRET"), payload)
    render json: { error: "Invalid signature" }, status: :unauthorized unless
      ActiveSupport::SecurityUtils.secure_compare(expected, sig.to_s)
  end

  def process_event(event)
    case event["type"]
    when "payment_intent.succeeded"  then PaymentService.fulfill(event.dig("data", "object"))
    when "payment_intent.payment_failed" then PaymentService.mark_failed(event.dig("data", "object"))
    when "payment_intent.canceled"   then OrderService.cancel(event.dig("data", "object", "id"))
    end
  end
end

Test 1 — MockCard race condition, threaded delivery

RSpec.describe "Webhook idempotency", type: :request do
  it "creates one order even when MockCard delivers the event twice" do
    # MockCard fires two deliveries to our test endpoint
    mc = HTTParty.post(
      "https://mockcard.io/api/v1/generate",
      headers: {
        "Content-Type"  => "application/json",
        "Authorization" => "Bearer #{ENV["MOCKCARD_KEY"]}",
      },
      body: {
        brand:         "visa",
        scenario:      "success",
        webhook_url:   ENV["TEST_WEBHOOK_URL"],
        simulate_race: true,
      }.to_json
    )

    data = JSON.parse(mc.body)

    # Give MockCard time to fire both deliveries
    sleep 2

    # Confirm MockCard actually made two delivery attempts
    status = HTTParty.get(
      "https://mockcard.io/api/v1/delivery-status/#{data["log_id"]}",
      headers: { "Authorization" => "Bearer #{ENV["MOCKCARD_KEY"]}" }
    )
    expect(JSON.parse(status.body)["attempts"]).to eq(2)

    # Only one order must exist
    expect(Order.count).to eq(1)
    expect(ProcessedEvent.count).to eq(1)
  end
end

Test 2 — 3DS abandonment: no zombie pending orders

RSpec.describe "3DS abandonment", type: :request do
  it "marks the order abandoned when the user closes the 3DS modal" do
    # MockCard fires payment_intent.canceled — user bailed on authentication
    mc = HTTParty.post(
      "https://mockcard.io/api/v1/generate",
      headers: {
        "Content-Type"  => "application/json",
        "Authorization" => "Bearer #{ENV["MOCKCARD_KEY"]}",
      },
      body: {
        scenario:    "3ds_abandoned",
        webhook_url: ENV["TEST_WEBHOOK_URL"],
      }.to_json
    )

    sleep 1

    order = Order.last
    # If this fails, you have zombie "pending" rows in production right now
    expect(order.status).not_to eq("pending")
    expect(%w[abandoned canceled]).to include(order.status)
  end
end

Java (Spring Boot)

The handler:

@PostMapping(value = "/stripe", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, String>> handleWebhook(
        @RequestBody String rawBody,
        @RequestHeader(value = "X-MockCard-Signature", required = false) String mockSig,
        @RequestHeader(value = "Stripe-Signature",     required = false) String stripeSig
) throws Exception {
    String sig = mockSig != null ? mockSig : stripeSig;

    if (!verifySignature(rawBody, sig)) {
        return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
    }

    JsonNode event   = new ObjectMapper().readTree(rawBody);
    String   eventId = event.get("id").asText();

    try {
        // DB unique constraint is the real lock — no check-then-insert
        processedEventRepository.save(new ProcessedEvent(eventId));
    } catch (DataIntegrityViolationException e) {
        return ResponseEntity.ok(Map.of("status", "already_processed"));
    }

    paymentService.handle(event);
    return ResponseEntity.ok(Map.of("status", "ok"));
}

Test 1 — MockCard fires two deliveries concurrently

@Test
void duplicateDeliveryFromMockCardCreatesOneOrderOnly() throws Exception {
    // Tell MockCard to fire the success event twice to our running server
    HttpHeaders mcHeaders = new HttpHeaders();
    mcHeaders.setContentType(MediaType.APPLICATION_JSON);
    mcHeaders.setBearerAuth(System.getenv("MOCKCARD_KEY"));

    String mcBody = """
        {
          "brand": "visa",
          "scenario": "success",
          "webhook_url": "%s",
          "simulate_race": true
        }
        """.formatted(System.getenv("TEST_WEBHOOK_URL"));

    ResponseEntity<Map> mcResp = restTemplate.exchange(
        "https://mockcard.io/api/v1/generate",
        HttpMethod.POST,
        new HttpEntity<>(mcBody, mcHeaders),
        Map.class
    );

    String logId = (String) mcResp.getBody().get("log_id");

    // Wait for MockCard to deliver both attempts
    Thread.sleep(2000);

    // Verify MockCard made 2 delivery attempts
    ResponseEntity<Map> statusResp = restTemplate.exchange(
        "https://mockcard.io/api/v1/delivery-status/" + logId,
        HttpMethod.GET,
        new HttpEntity<>(mcHeaders),
        Map.class
    );
    assertEquals(2, statusResp.getBody().get("attempts"));

    // Only one processed event and one order should exist
    assertEquals(1, processedEventRepository.count());
    assertEquals(1, orderRepository.count());
}

Test 2 — Limbo state: no paid order created

@Test
void limboScenarioFromMockCardDoesNotCreatePaidOrder() throws Exception {
    HttpHeaders mcHeaders = new HttpHeaders();
    mcHeaders.setContentType(MediaType.APPLICATION_JSON);
    mcHeaders.setBearerAuth(System.getenv("MOCKCARD_KEY"));

    // MockCard fires payment_intent.payment_failed with network-level decline reason
    String mcBody = """
        {"scenario": "limbo", "webhook_url": "%s"}
        """.formatted(System.getenv("TEST_WEBHOOK_URL"));

    restTemplate.exchange(
        "https://mockcard.io/api/v1/generate",
        HttpMethod.POST,
        new HttpEntity<>(mcBody, mcHeaders),
        Map.class
    );

    Thread.sleep(1000);

    // No paid orders should exist
    assertEquals(0, orderRepository.countByStatus("paid"));

    // Must be in a terminal failure state
    Order order = orderRepository.findAll().get(0);
    assertTrue(
        List.of("failed", "declined").contains(order.getStatus()),
        "Expected failed/declined, got: " + order.getStatus()
    );
}

PHP (plain)

The handler:

<?php
$rawBody = file_get_contents("php://input");
$sig     = $_SERVER["HTTP_X_MOCKCARD_SIGNATURE"]
        ?? $_SERVER["HTTP_STRIPE_SIGNATURE"]
        ?? "";

$expected = "sha256=" . hash_hmac("sha256", $rawBody, getenv("WEBHOOK_SECRET"));

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    echo json_encode(["error" => "Invalid signature"]);
    exit;
}

$event = json_decode($rawBody, true);
$pdo   = new PDO(getenv("DATABASE_URL"));

$stmt = $pdo->prepare(
    "INSERT INTO processed_events (event_id, processed_at)
     VALUES (?, NOW())
     ON CONFLICT (event_id) DO NOTHING"
);
$stmt->execute([$event["id"]]);

if ($stmt->rowCount() === 0) {
    echo json_encode(["status" => "already_processed"]);
    exit;
}

processEvent($event, $pdo);
echo json_encode(["status" => "ok"]);

Test 1 — MockCard race condition, PHPUnit

public function testMockCardRaceConditionCreatesOneOrder(): void
{
    // Ask MockCard to deliver the same event twice to our test server
    $ch = curl_init("https://mockcard.io/api/v1/generate");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Content-Type: application/json",
            "Authorization: Bearer " . getenv("MOCKCARD_KEY"),
        ],
        CURLOPT_POSTFIELDS => json_encode([
            "brand"         => "visa",
            "scenario"      => "success",
            "webhook_url"   => getenv("TEST_WEBHOOK_URL"),
            "simulate_race" => true,
        ]),
    ]);

    $mc    = json_decode(curl_exec($ch), true);
    $logId = $mc["log_id"];
    curl_close($ch);

    // Give MockCard time to fire both deliveries
    sleep(2);

    // Confirm MockCard made two attempts
    $ch = curl_init("https://mockcard.io/api/v1/delivery-status/{$logId}");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("MOCKCARD_KEY")],
    ]);
    $status = json_decode(curl_exec($ch), true);
    curl_close($ch);

    $this->assertEquals(2, $status["attempts"]);

    // Only one order and one processed_event row must exist
    $count = $this->pdo->query("SELECT COUNT(*) FROM processed_events")->fetchColumn();
    $this->assertEquals(1, $count);

    $orders = $this->pdo->query("SELECT COUNT(*) FROM orders")->fetchColumn();
    $this->assertEquals(1, $orders);
}

Test 2 — Tampered signature is rejected before any DB write

public function testTamperedSignatureIsRejectedWithoutSideEffects(): void
{
    // Get a valid payload from MockCard but swap in a bad signature
    $ch = curl_init("https://mockcard.io/api/v1/generate");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Content-Type: application/json",
            "Authorization: Bearer " . getenv("MOCKCARD_KEY"),
        ],
        CURLOPT_POSTFIELDS => json_encode(["scenario" => "success"]),
    ]);

    $mc = json_decode(curl_exec($ch), true);
    curl_close($ch);

    $payload      = json_encode($mc["webhook_event"]["payload"]);
    $badSignature = "sha256=" . str_repeat("a", 64); // forged

    $response = $this->postWebhook($payload, $badSignature);

    // Must be rejected — 401, no DB writes
    $this->assertEquals(401, $response->getStatusCode());

    $count = $this->pdo->query("SELECT COUNT(*) FROM processed_events")->fetchColumn();
    $this->assertEquals(0, $count);
}

CI/CD: every PR, automatically

# .github/workflows/payment-chaos.yml
name: Payment Chaos Tests

on: [push, pull_request]

jobs:
  webhook-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s

    env:
      DATABASE_URL: postgresql://postgres:test@localhost/testdb
      WEBHOOK_SECRET: test-secret
      MOCKCARD_KEY: ${{ secrets.MOCKCARD_KEY }}
      TEST_WEBHOOK_URL: ${{ secrets.TEST_WEBHOOK_URL }} # ngrok tunnel or similar

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci && npm run db:migrate
      - run: npm start &
      - run: sleep 3
      - run: npm run test:chaos

The TEST_WEBHOOK_URL is a stable tunnel (ngrok, Cloudflare, or your staging environment) that MockCard can reach to deliver webhooks during CI. MockCard's log_id delivery status polling lets you assert that the webhook was actually delivered — no more tests that pass because the webhook silently failed to arrive.


What Pro unlocks

The free tier covers the happy path and basic race condition testing. Pro is where the CI workflow becomes practical:

Delivery logs with log_id — every /generate call returns a log_id. Poll /api/v1/delivery-status/{log_id} to assert exactly how many delivery attempts were made and whether they succeeded. Without this, you are guessing.

simulate_race flag — free tier generates cards. Pro delivers the same event twice and lets you prove your handler is idempotent. This is the flag that would have stopped the Black Friday incident.

All 4 chaos modes — 3DS abandonment and limbo states are Pro. These are the scenarios that produce no error, just silent broken state. They are the hardest to discover in production and the easiest to prevent with a one-line test.

No rate-limit anxiety — running chaos tests on every PR commit adds up fast. Pro gives you the headroom to not think about it.


The thing I actually learned from Black Friday

The double-charge incident was not a code quality failure. The code was fine. It was a testing coverage failure — we had not thought to test "what if this exact function is called twice, concurrently, with the same inputs?"

MockCard makes that question a two-minute test instead of a two-hour investigation. You call the API, point it at your server, and it either breaks you or it doesn't. Either outcome is useful.

If you've shipped a payment system without this, go add it this week. Good luck. Ship boring payment code.