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
- Node.js 20.11+ and TypeScript 5.4, with the handler exposed as an app factory (
createApp()), not a module that binds a port on import. - Vitest 1.6 (or Jest 29) with
globalSetupsupport for starting and stopping the server once per suite. - A handler that verifies signatures with the shared helper from the overview and reads the raw body as a
Buffer— in Fastify,addContentTypeParser("application/json", { parseAs: "buffer" }, …). - A persistence layer reachable from tests: a disposable Postgres schema or an in-memory repository injected into
createApp(). - At least one captured real delivery per event type, so the mock’s header names and body shape are copied rather than invented.
- A test-only signing secret, distinct from every production value and asserted as such at boot.
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.
// 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.
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.
// 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
- Signing an object instead of the exact bytes. Calling
JSON.stringifyonce for the signature and again for the request produces two strings that can differ in key order or whitespace, and the handler rejects a payload the mock believes is valid. Serialise once, keep the string, sign and send that. - A new delivery id on every retry attempt. If the mock generates a fresh id per attempt, your deduplication is never exercised and the suite happily passes with a handler that writes a row per attempt. The delivery id and event id are constant for a logical delivery; only the attempt counter moves.
- A mock that answers instead of asks. Some teams build a fake provider endpoint their code calls, then assert the fake was called. That tests the mock. The direction that matters is inbound: the mock must initiate the request and your production handler must respond. What the mock genuinely cannot check — that your endpoint is registered and subscribed at all — belongs to a run against the provider’s sandbox.
- Leaking state between scenarios. Duplicate and ordering assertions are all about accumulated state, so a store that is not reset between tests turns a real regression into a passing test or a passing change into a mystery failure. Truncate in
beforeEach, and give each test its own event ids so a leak is visible rather than silent. - Fixed timestamps that drift into rejection. Hard-coding a signing timestamp works until the tolerance window rejects it, usually months later on someone else’s machine. Sign relative to
Date.now()and pass an explicit offset only in the tests that deliberately probe skew.
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.
Related
- Recording and replaying webhook fixtures — where the bodies your sender posts should come from.
- Testing against provider sandbox environments — the level above, and what it covers that a mock cannot.
- How to design idempotent webhook consumers — the handler behaviour these duplicate scenarios assert.
- Webhook mocking and sandbox environments — how this level fits the rest of the ladder.