Building a Mock Webhook Server for Integration Tests

Your CI runner has no provider to talk to, so if the pipeline is going to prove anything about webhook ingestion it has to emit the deliveries itself — signed, over real HTTP, at the handler you are about to ship. That is the level 2 rung described in webhook mocking and sandbox environments, and it is where the majority of integration defects are actually caught: a body parser that mutates bytes before verification, a duplicate that writes a second ledger row, an out-of-order pair that leaves a subscription in a state your code says is impossible. The bodies you send should come from recorded webhook fixtures so the mock is not testing your imagination, but the sending, timing and repetition are entirely yours to control.

The component you are building is deliberately small: roughly two hundred lines that turn a scenario description into a sequence of signed HTTP requests and collect what came back. It is not a fake provider API with a dashboard and a database — that path leads to maintaining a second product. It is a test-time client with three powers a real provider will not give you on demand: exact timing, exact repetition, and exact ordering.

Prerequisites

Step 1: Model one delivery as a signed request

Start from a descriptor rather than from a request. A delivery is an event body plus the delivery metadata a provider attaches: a delivery id that stays constant across retry attempts, an attempt counter, a timestamp, and the key it was signed with. Keeping those as data means a scenario can be written as an array and a retry becomes “the same descriptor with a higher attempt number” rather than a second hand-built request.

// test/support/delivery.ts
import { signBody, TEST_SECRET } from "./signing";

export interface DeliverySpec {
  eventId: string;          // stable across retries — the idempotency key
  deliveryId: string;       // stable across retries — one logical delivery
  attempt: number;          // 1 for first send, 2+ for provider retries
  type: string;             // "invoice.paid"
  data: Record<string, unknown>;
  secret?: string;          // override to simulate a rotated or wrong key
  atMs?: number;            // override to simulate clock skew
}

export interface RenderedDelivery {
  body: string;
  headers: Record<string, string>;
}

export function renderDelivery(spec: DeliverySpec): RenderedDelivery {
  const body = JSON.stringify({
    id: spec.eventId,
    type: spec.type,
    created_at: new Date(spec.atMs ?? Date.now()).toISOString(),
    data: spec.data,
  });
  const signed = signBody(body, spec.secret ?? TEST_SECRET, spec.atMs ?? Date.now());
  return {
    body,
    headers: {
      ...signed.headers,
      "x-webhook-delivery-id": spec.deliveryId,
      "x-webhook-attempt": String(spec.attempt),
      "user-agent": "MockWebhookSender/1.0",
    },
  };
}

Two details matter more than they look. body is serialised exactly once and both signed and sent as that same string — the most common self-inflicted mock bug is signing an object and then re-serialising it for the request, which produces a different byte sequence and a signature that can never match. And secret is overridable, because a suite that can only send valid signatures never proves the rejection path works.

Step 2: Drive the real handler over a loopback port

Bind the application to port 0, let the OS allocate a free port, and send real requests to it. In-process request injection (app.inject(), supertest’s virtual server) is faster, but it skips the HTTP server layer where header casing, chunked bodies and content-length handling live — precisely the layer a provider will exercise. On a loopback interface the cost is under a millisecond per request, which buys back all of that realism.

Mock sender delivery sequence with a retry The test runner starts the mock sender, which posts a signed delivery, receives a 500, resends the same delivery id, receives a 200, and returns the attempt log for assertions. Test runner Mock sender Handler on :0 run scenario signed POST, attempt 1 500 dependency down same delivery id, attempt 2 200 accepted attempt log
The sender keeps the delivery id constant across attempts, which is what lets the test assert that attempt 2 was deduplicated rather than processed twice.
// test/support/sender.ts
import { createServer, type Server } from "node:http";
import { renderDelivery, type DeliverySpec } from "./delivery";

export interface AttemptResult {
  deliveryId: string;
  attempt: number;
  status: number;
  body: string;
  latencyMs: number;
}

export class MockWebhookSender {
  readonly attempts: AttemptResult[] = [];
  constructor(private readonly endpoint: string) {}

  async deliver(spec: DeliverySpec): Promise<AttemptResult> {
    const rendered = renderDelivery(spec);
    const startedAt = performance.now();
    const res = await fetch(this.endpoint, {
      method: "POST",
      headers: rendered.headers,
      body: rendered.body,
    });
    const result: AttemptResult = {
      deliveryId: spec.deliveryId,
      attempt: spec.attempt,
      status: res.status,
      body: await res.text(),
      latencyMs: performance.now() - startedAt,
    };
    this.attempts.push(result);
    return result;
  }
}

// test/setup.ts — one server for the whole suite
import { createApp } from "../src/app";

let server: Server;
export let endpoint = "";

export async function setup() {
  const app = createApp({ webhookSecret: process.env.TEST_WEBHOOK_SECRET! });
  server = createServer(app.callback());
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const address = server.address();
  if (typeof address === "string" || address === null) throw new Error("no port");
  endpoint = `http://127.0.0.1:${address.port}/webhooks/billing`;
}

export async function teardown() {
  await new Promise<void>((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve())),
  );
}

Step 3: Inject retries, duplicates and out-of-order arrivals

A real provider does three things your happy-path test never will. It retries a delivery your handler failed, reusing the same delivery id. It occasionally delivers twice because your 200 was lost after the work committed. And it emits causally related events whose arrival order does not match their creation order, because they travelled through different queues. Each of these is a distinct state transition in your handler, and the mock sender exists to force all three deterministically.

Per-event handler states exercised by the scenario runner An event moves from unseen to processing, then either commits, becomes retry pending after a failure, or is ignored as a duplicate when the same event id arrives again. Unseen no stored key Processing row locked Committed effects written Retry pending nothing written Ignored duplicate, 200 first attempt handler 200 handler 500 retry arrives same event id
Each injected scenario drives one transition: a failure must leave nothing written, and a repeat of a committed event must land in Ignored rather than back in Processing.

The scenario runner below expresses those three shapes declaratively. retryUntil re-sends on any retryable status, mirroring the exponential backoff a provider applies but without the wall-clock wait; duplicate repeats an already-accepted delivery; outOfOrder sends a pair in the wrong sequence so you can prove your handler tolerates it, which is the practical consequence of the ordering guarantees most providers decline to make.

// test/support/scenarios.ts
import { MockWebhookSender } from "./sender";
import type { DeliverySpec } from "./delivery";

const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);

export async function retryUntil(
  sender: MockWebhookSender,
  spec: DeliverySpec,
  maxAttempts = 4,
): Promise<number> {
  let attempt = 1;
  while (attempt <= maxAttempts) {
    const res = await sender.deliver({ ...spec, attempt });
    if (!RETRYABLE.has(res.status)) return res.status;
    attempt += 1;
  }
  throw new Error(`delivery ${spec.deliveryId} never settled in ${maxAttempts} attempts`);
}

export async function duplicate(sender: MockWebhookSender, spec: DeliverySpec, times = 2) {
  const statuses: number[] = [];
  for (let i = 1; i <= times; i += 1) {
    // Same eventId AND same deliveryId: the provider believes it never got a 200.
    statuses.push((await sender.deliver({ ...spec, attempt: i })).status);
  }
  return statuses;
}

export async function outOfOrder(sender: MockWebhookSender, earlier: DeliverySpec, later: DeliverySpec) {
  // The causally later event is delivered first — a legal provider behaviour.
  const second = await sender.deliver({ ...later, attempt: 1 });
  const first = await sender.deliver({ ...earlier, attempt: 1 });
  return { second, first };
}

Step 4: Assert on responses and observable side effects

A status code alone is a weak assertion: a handler that swallows every event and returns 200 passes it. Every scenario needs a paired claim about state — one ledger row after two duplicate deliveries, a subscription still active after a stale subscription.updated arrives late, zero rows after a failed attempt. Assert on the store through the same repository the application uses, so a schema change breaks the test rather than silently invalidating it.

From response status to the right assertion An observed status branches into success, client error and retryable error, each leading to a specific claim the test must make about stored state. Observed status from one attempt 2xx accepted effect written exactly once 4xx rejected no effect, no retry 5xx or timeout nothing partially written replay must be a no-op signature or body is wrong retry must reach 2xx
Every branch ends in a claim about stored state, because a status code on its own cannot distinguish a correct handler from one that quietly discards events.
// test/webhook-delivery.test.ts
import { describe, expect, it, beforeEach } from "vitest";
import { MockWebhookSender } from "./support/sender";
import { duplicate, outOfOrder, retryUntil } from "./support/scenarios";
import { ledgerRowsFor, subscriptionState, resetStore } from "./support/store";
import { endpoint } from "./setup";

const invoicePaid = (eventId: string, deliveryId: string) => ({
  eventId,
  deliveryId,
  attempt: 1,
  type: "invoice.paid",
  data: { invoice_id: "in_9001", amount_cents: 4200, currency: "EUR" },
});

describe("webhook ingestion", () => {
  beforeEach(async () => resetStore());

  it("writes the ledger row exactly once for a duplicated delivery", async () => {
    const sender = new MockWebhookSender(endpoint);
    const statuses = await duplicate(sender, invoicePaid("evt_1", "dlv_1"), 3);
    expect(statuses).toEqual([200, 200, 200]);
    expect(await ledgerRowsFor("in_9001")).toHaveLength(1);
  });

  it("settles a retried delivery without double-writing", async () => {
    const sender = new MockWebhookSender(endpoint);
    const final = await retryUntil(sender, invoicePaid("evt_2", "dlv_2"));
    expect(final).toBe(200);
    expect(await ledgerRowsFor("in_9001")).toHaveLength(1);
    expect(sender.attempts.at(-1)!.attempt).toBeGreaterThanOrEqual(1);
  });

  it("keeps the newest state when events arrive out of order", async () => {
    const sender = new MockWebhookSender(endpoint);
    const created = { ...invoicePaid("evt_3", "dlv_3"), type: "subscription.created" };
    const cancelled = { ...invoicePaid("evt_4", "dlv_4"), type: "subscription.cancelled" };
    await outOfOrder(sender, created, cancelled);
    expect(await subscriptionState("in_9001")).toBe("cancelled");
  });

  it("rejects a delivery signed with the wrong secret", async () => {
    const sender = new MockWebhookSender(endpoint);
    const res = await sender.deliver({ ...invoicePaid("evt_5", "dlv_5"), secret: "whsec_wrong" });
    expect(res.status).toBe(401);
    expect(await ledgerRowsFor("in_9001")).toHaveLength(0);
  });
});

Verification and testing

The suite’s own credibility rests on one negative test: prove the verification path is live by tampering with a body after signing it. If that test passes while the handler still returns 200, every other assertion in the file is worthless, because the mock is talking to a handler that accepts anything.

it("rejects a body mutated after signing", async () => {
  const spec = invoicePaid("evt_6", "dlv_6");
  const rendered = renderDelivery(spec);
  const tampered = rendered.body.replace('"amount_cents":4200', '"amount_cents":1');
  const res = await fetch(endpoint, {
    method: "POST",
    headers: rendered.headers,   // signature still covers the ORIGINAL bytes
    body: tampered,
  });
  expect(res.status).toBe(401);
});

Run the suite in CI with a dedicated secret and no network egress, so an accidental call to a real provider fails loudly rather than passing quietly:

# .github/workflows/webhook-integration.yml
name: webhook-integration
on: [pull_request]
jobs:
  mock-sender:
    runs-on: ubuntu-latest
    env:
      TEST_WEBHOOK_SECRET: whsec_test_only_never_in_prod
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
      - run: npm run test:integration   # required check, no provider credentials present

A useful supplementary check is timing: record latencyMs per attempt and fail the suite if the median exceeds your acknowledgement budget. It catches a synchronous dependency creeping into the request path long before a load test would.

Failure modes and gotchas

Frequently Asked Questions

Should the deliveries in one scenario be sent concurrently rather than one after another?

Serial sends prove deduplication is implemented; concurrent sends prove it is safe. Add at least one scenario that fires two identical descriptors through Promise.all, because that is the shape that exposes a check-then-insert race where both requests read an empty idempotency table and both write. If the concurrent version fails while the serial version passes, the fix belongs in the database as a unique constraint on the event id, not in application-level locking.

The handler returns 202 and does the work in the background — how does the test assert on state without sleeping?

Poll the repository on a short interval with a hard deadline, and fail with the last observed state rather than a bare timeout so the message is diagnosable. A fixed sleep is either flaky or slow, and usually both. If the application exposes a way to await its own in-flight work — draining the queue, flushing a worker — prefer that hook and keep the polling helper for the cases where it does not.

Does driving the handler over plain HTTP on loopback hide anything a real delivery would expose?

It hides everything your ingress does: TLS termination, protocol downgrade, header normalisation, body size limits, and any proxy that buffers or rewrites the request. Those layers can strip a header or alter the bytes the signature covers, and no loopback test will see it. Cover them once per environment with a real delivery to the deployed URL rather than trying to reproduce the proxy inside CI.

Should the sender reproduce the provider's real delays between retry attempts?

No — wall-clock waits are what turn a two-second suite into a two-minute one, and the delay is the provider's behaviour rather than yours. Assert on the attempt sequence and the resulting state, and send the next attempt immediately. The exception is a handler with its own time-dependent logic, such as a lock lease or a stale-event cutoff, where a fake clock is the right tool rather than a real pause.

Is it worth sending gzipped or chunked bodies from the mock?

One test each is worth it if the provider or your own ingress ever uses them, because compression and chunking both change where the raw bytes are observed. A stack that decompresses before your buffer parser runs will verify against decoded bytes while the provider signed the encoded ones, or the reverse, and the mismatch surfaces only in production. Once you have established which representation the signature covers, a single regression test per encoding is enough.

How should the store be isolated when the runner executes test files in parallel workers?

Give each worker its own database schema or its own application instance built from the same factory, so a truncation in one file cannot delete rows another file is asserting on. Sharing one server and one schema across parallel files produces failures that move when you reorder tests, which is the hardest kind to diagnose. If a per-worker schema is impractical, namespace every event id by worker and assert only on rows the test itself created.