All posts
nextjstypescriptecommercepaymentswebhookstestingmockcardseries

Webhooks, Idempotency, and Chaos Scenarios — Completing the Checkout (Part 3 of 3)

April 9, 20268 min read

This is Part 3 of a 3-part series. We build the webhook handler, lock in idempotency, and run chaos scenarios against the entire flow.

Series: Part 1 — The Store · Part 2 — MockCard Integration · Part 3 — Webhooks and Edge Cases


Orders in PENDING_WEBHOOK mean payment was submitted but not confirmed. In a real system that confirmation arrives via webhook — an HTTP POST from the gateway telling you the charge succeeded, failed, or was reversed. Without it, you have captured money that you cannot account for.

Part 3 closes the loop. We build the webhook handler, make it idempotent, and then use MockCard Pro's chaos scenarios to test every failure mode before production does it for you.


The Webhook Handler

The handler has three jobs: verify the signature, deduplicate the event, and update the order.

// app/api/webhook/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { createHmac, timingSafeEqual } from "crypto";

const WEBHOOK_SECRET = process.env.MOCKCARD_WEBHOOK_SECRET!;

function verifySignature(payload: string, header: string | null): boolean {
  if (!header) return false;
  // MockCard signature format: "sha256=<hex>"
  const expected = "sha256=" + createHmac("sha256", WEBHOOK_SECRET)
    .update(payload)
    .digest("hex");
  try {
    return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
  } catch {
    return false;
  }
}

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

  // ── 1. Verify signature ───────────────────────────────────────────────
  if (!verifySignature(payload, sig)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(payload);
  const eventId: string = event.id;

  // ── 2. Idempotency check ──────────────────────────────────────────────
  // Real gateways retry failed deliveries. Without this, a retry becomes a second fulfilment.
  const already = await prisma.processedEvent.findUnique({ where: { id: eventId } });
  if (already) {
    return NextResponse.json({ received: true }); // 200 so the gateway stops retrying
  }

  const paymentIntentId: string = event.data?.object?.id;

  // ── 3. Route by event type ────────────────────────────────────────────
  if (event.type === "payment_intent.succeeded") {
    await prisma.$transaction([
      prisma.order.updateMany({
        where: { paymentIntentId, status: "PENDING_WEBHOOK" },
        data:  { status: "FULFILLED" },
      }),
      prisma.processedEvent.create({ data: { id: eventId } }),
    ]);
  } else if (
    event.type === "payment_intent.payment_failed" ||
    event.type === "payment_intent.canceled"
  ) {
    await prisma.$transaction([
      prisma.order.updateMany({
        where: { paymentIntentId, status: "PENDING_WEBHOOK" },
        data:  { status: "FAILED" },
      }),
      prisma.processedEvent.create({ data: { id: eventId } }),
    ]);
  } else {
    // Unknown event type — record so we do not process it again, but take no action
    await prisma.processedEvent.create({ data: { id: eventId } });
  }

  return NextResponse.json({ received: true });
}

Three things worth pausing on:

The signature check uses timingSafeEqual, not ===. String comparison short-circuits on the first mismatch, which leaks timing information. timingSafeEqual always takes the same time regardless of where the strings differ. This is not hypothetical — it is why every security library uses constant-time comparison for secrets.

The idempotency check is a database read before the update, but the write is atomic. The processedEvent.create and the order.updateMany run in a single transaction. If the process dies between the two, the transaction rolls back and the next retry processes correctly.

updateMany with a status filter. We only update orders that are still in PENDING_WEBHOOK. If an order somehow moved to FULFILLED already (race condition with a duplicate webhook), this is a no-op. Safe by default.


Registering the Webhook

When you call MockCard, pass your webhook URL:

// app/api/checkout/route.ts  — update the charge() call

result = await charge({
  amount:      serverTotal,
  currency:    "INR",
  scenario,
  card_number: form.cardNumber,
  expiry:      form.expiry,
  cvv:         form.cvv,
  name:        form.name,
  webhook_url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhook/stripe`,
});

MockCard delivers the webhook to that URL after processing. For local development, use a tunnel (ngrok, Cloudflare Tunnel) or use MockCard's built-in webhook dashboard to inspect and replay deliveries without needing a public URL.


Testing the Happy Path

With the webhook handler in place, the full flow is:

  1. Use ?scenario=success at checkout (as set up in Part 2)
  2. Checkout route calls MockCard → receives status: "success" with a payment_intent_id
  3. Order moves to PENDING_WEBHOOK, paymentIntentId is stored
  4. MockCard delivers payment_intent.succeeded to your webhook URL
  5. Webhook handler verifies signature, deduplicates, updates order to FULFILLED

The order page shows "Order confirmed". The full cycle — checkout to fulfilment — without touching a real gateway.


Chaos Scenarios

The happy path is the easy part. The scenarios that break production payment flows are the ones nobody tests. MockCard Pro exposes four chaos scenarios that simulate real infrastructure failures.

Duplicate Webhook Delivery

Gateways retry webhook delivery when they do not receive a 200 response quickly enough. Network timeouts and slow database writes trigger retries even when the original delivery succeeded. The result: two payment_intent.succeeded events for the same payment.

// test/webhook.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { POST } from "@/app/api/webhook/stripe/route";
import { prisma } from "@/lib/prisma";
import { createHmac } from "crypto";

const WEBHOOK_SECRET = process.env.MOCKCARD_WEBHOOK_SECRET!;

describe("webhook idempotency", () => {
  it("processes the first delivery and ignores the second", async () => {
    const order = await prisma.order.create({
      data: {
        email: "test@example.com", name: "Test User",
        items: [], total: 49900,
        status: "PENDING_WEBHOOK",
        paymentIntentId: "pi_mock_idempotency_test",
      },
    });

    const event = {
      id:   "evt_duplicate_001",
      type: "payment_intent.succeeded",
      data: { object: { id: "pi_mock_idempotency_test" } },
    };

    const sig = "sha256=" + createHmac("sha256", WEBHOOK_SECRET).update(JSON.stringify(event)).digest("hex");

    // First delivery
    const res1 = await POST(new Request("http://localhost/api/webhook/stripe", {
      method: "POST",
      headers: { "x-mockcard-signature": sig },
      body: JSON.stringify(event),
    }) as any);
    expect(res1.status).toBe(200);

    // Second delivery — same event, same ID
    const res2 = await POST(new Request("http://localhost/api/webhook/stripe", {
      method: "POST",
      headers: { "x-mockcard-signature": sig },
      body: JSON.stringify(event),
    }) as any);
    expect(res2.status).toBe(200);

    const updated = await prisma.order.findUnique({ where: { id: order.id } });
    expect(updated?.status).toBe("FULFILLED"); // exactly once, not twice
  });
});

Delayed Webhook (PENDING_WEBHOOK Limbo)

Some gateways take minutes to deliver the webhook. Orders sit in PENDING_WEBHOOK long enough for users to email support, refresh the page, and attempt repurchase.

MockCard Pro's simulate_race flag triggers a delayed webhook — the charge response arrives immediately but the webhook is delivered after a configurable delay. This tests your order status page and support tooling, not just the handler itself.

// test/checkout.test.ts — test the delayed webhook scenario
it("order stays PENDING_WEBHOOK until webhook arrives", async () => {
  const result = await charge({
    amount:      49900,
    currency:    "INR",
    scenario:    "success",
    card_number: "4111111111111111",
    expiry:      "12/29",
    cvv:         "123",
    name:        "Test User",
    simulate_race: true,   // MockCard Pro: delays webhook delivery
    webhook_url:   `${process.env.NEXT_PUBLIC_APP_URL}/api/webhook/stripe`,
  });

  expect(result.status).toBe("success");

  // Immediately after charge, the order should still be PENDING_WEBHOOK
  // The webhook has not arrived yet
  const order = await prisma.order.findFirst({
    where: { paymentIntentId: (result as MockCardSuccess).payment_intent_id },
  });
  expect(order?.status).toBe("PENDING_WEBHOOK");
});

Capture After Decline

A card is declined at checkout, the user refreshes and uses a different card, and the first payment — which was supposed to be void — later fires a delayed success webhook. Without status filtering in the webhook handler, this fulfils an order that was already failed.

The fix is already in the handler: updateMany filters by status: "PENDING_WEBHOOK". A failed order does not match, so the stale webhook is a no-op.

it("stale success webhook does not fulfil a FAILED order", async () => {
  const order = await prisma.order.create({
    data: {
      email: "test@example.com", name: "Test User",
      items: [], total: 49900,
      status: "FAILED",   // already failed — user repurchased
      paymentIntentId: "pi_mock_stale_001",
    },
  });

  const event = {
    id:   "evt_stale_success",
    type: "payment_intent.succeeded",
    data: { object: { id: "pi_mock_stale_001" } },
  };

  const sig = "sha256=" + createHmac("sha256", WEBHOOK_SECRET).update(JSON.stringify(event)).digest("hex");
  const res = await POST(new Request("http://localhost/api/webhook/stripe", {
    method: "POST",
    headers: { "x-mockcard-signature": sig },
    body: JSON.stringify(event),
  }) as any);

  expect(res.status).toBe(200);
  const updated = await prisma.order.findUnique({ where: { id: order.id } });
  expect(updated?.status).toBe("FAILED"); // unchanged
});

3DS Abandonment

The user is redirected to the 3DS challenge and closes the browser. Your /api/3ds-return route never fires. The order sits in PENDING_PAYMENT indefinitely.

The clean solution is a cron job that marks orders older than 30 minutes as CANCELLED if they are still in PENDING_PAYMENT.

// app/api/cron/expire-orders/route.ts
// Add to vercel.json: { "crons": [{ "path": "/api/cron/expire-orders", "schedule": "*/15 * * * *" }] }

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

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); // 30 minutes ago

  const { count } = await prisma.order.updateMany({
    where: {
      status:    "PENDING_PAYMENT",
      createdAt: { lt: cutoff },
    },
    data: { status: "CANCELLED" },
  });

  return NextResponse.json({ cancelled: count });
}

The Full Test Suite

// test/webhook.test.ts — complete suite

describe("webhook handler", () => {
  it("fulfils order on payment_intent.succeeded");
  it("fails order on payment_intent.payment_failed");
  it("fails order on payment_intent.canceled");
  it("ignores duplicate events (idempotency)");
  it("rejects request with invalid signature");
  it("rejects request with missing signature");
  it("does not fulfil a FAILED order on stale success webhook");
  it("does not fulfil a CANCELLED order on stale success webhook");
  it("handles unknown event type gracefully");
  it("handles missing payment_intent_id in event gracefully");
});

All ten cases are coverable without a gateway account, without a real card, and without a live webhook endpoint. MockCard generates the exact event shapes that a real gateway would deliver. Your test suite runs in CI with zero external dependencies.


Where We Are

Across all three parts you have built:

  • A clean order lifecycle — five statuses, all transitions intentional and logged
  • Full payment integration — every decline type, 3DS, and network failure handled in the checkout
  • A robust webhook handler — signature verification, idempotency, status-safe updates
  • Chaos scenario tests — duplicate delivery, delayed webhooks, stale captures, 3DS abandonment

The production payment flow most developers ship handles the happy path and maybe one or two decline types. This one handles everything. Before a single real card is charged, every failure mode has been exercised. That is the point of MockCard: the bugs that appear in production payment flows are not surprising — they are predictable. Test them in development before they surprise a real customer.