Recording and Replaying Webhook Fixtures
A fixture is the cheapest way to stop guessing what a provider sends: capture one genuine delivery, keep it forever, and every future test runs against bytes the provider actually produced rather than bytes you believed it would produce. This is the third rung of the ladder in webhook mocking and sandbox environments, and it is what stops a mock sender from quietly encoding your assumptions instead of the provider’s behaviour. A single captured charge.dispute.created teaches you more about field naming, null conventions and numeric encoding than a week of reading reference documentation.
The work is not the capture. The work is everything that has to happen before a production delivery is allowed to become a file in a repository that every engineer, contractor and CI job can read: the personal data has to leave, the secrets have to leave, and the signature — which redaction necessarily destroys — has to be rebuilt so the fixture still exercises verification. Then, because a frozen fixture slowly stops resembling live traffic, something has to notice when the provider changes the payload underneath you.
Prerequisites
- Node.js 20.11+ and TypeScript 5.4, with a webhook route that can read the untouched request body as a
Buffer. - A durable delivery log — the store described in inspecting and replaying webhook deliveries — holding raw bytes, headers and the receipt timestamp.
- A redaction key held in your secrets manager, separate from any signing secret, used only to pseudonymise identifiers.
- Vitest 1.6 plus Ajv 8 for shape assertions, and the shared signing helper so fixtures are re-signed with the same construction the handler verifies.
- Written sign-off on what may be committed: most teams need legal or privacy approval before production payloads enter version control, even redacted.
Step 1: Capture the raw delivery before anything touches it
Capture at the earliest hook in the request lifecycle. If a JSON parser runs first, the bytes you save are a re-serialisation — key order may change, numeric precision may shift, and the provider signature will never verify against them again. Save the body as base64 or bytea, save every header including the ones you think are irrelevant, and save the receipt time, because the signature covers a timestamp you will need to reason about later.
// src/capture.ts — runs before any body parser
import type { FastifyInstance } from "fastify";
import { pool } from "./db";
export function registerCapture(app: FastifyInstance) {
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
done(null, body); // hand the parser the raw Buffer, untouched
});
app.addHook("preHandler", async (req) => {
if (!req.url.startsWith("/webhooks/")) return;
await pool.query(
`INSERT INTO webhook_captures (delivery_id, path, headers, raw_body, received_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (delivery_id) DO NOTHING`,
[
req.headers["x-webhook-delivery-id"] ?? `local_${Date.now()}`,
req.url,
JSON.stringify(req.headers),
req.body as Buffer,
],
);
});
}
Capture broadly and prune later. Disk is cheap, and the delivery you did not keep is always the one that would have explained the incident. A fourteen-day retention window on the capture table with a manual “promote to fixture” step gives you a large enough pool to find an example of every event type without turning the table into a permanent archive of customer data.
Step 2: Redact secrets and personal data deterministically
Redaction is a field-level policy, not a regex sweep. Decide per field whether it is dropped, masked to a shape-preserving placeholder, or pseudonymised through a keyed hash — and default to dropping anything not explicitly listed, so a field the provider adds next month cannot smuggle personal data into the repository. Pseudonymisation matters more than it first appears: if customer_id becomes a random value in each fixture, two fixtures that referred to the same customer no longer do, and any test about correlated events becomes untestable.
// tools/redact.ts
import { createHmac } from "node:crypto";
type Treatment = "keep" | "drop" | "hash" | { mask: string };
const POLICY: Record<string, Treatment> = {
"id": "keep",
"type": "keep",
"created_at": "keep",
"data.amount_cents": "keep",
"data.currency": "keep",
"data.customer.id": "hash",
"data.customer.email": "hash",
"data.customer.address_line1": "drop",
"data.card.last4": { mask: "4242" },
};
const REDACTION_KEY = process.env.FIXTURE_REDACTION_KEY!;
function pseudonym(value: string): string {
return "px_" + createHmac("sha256", REDACTION_KEY).update(value).digest("hex").slice(0, 16);
}
export function redact(input: unknown, path = ""): unknown {
if (Array.isArray(input)) return input.map((item) => redact(item, path));
if (input === null || typeof input !== "object") return input;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
const fieldPath = path ? `${path}.${key}` : key;
const rule = POLICY[fieldPath];
if (value !== null && typeof value === "object" && rule === undefined) {
out[key] = redact(value, fieldPath); // descend into unlisted objects
continue;
}
if (rule === undefined || rule === "drop") continue; // default-deny
if (rule === "keep") out[key] = value;
else if (rule === "hash") out[key] = pseudonym(String(value));
else out[key] = rule.mask;
}
return out;
}
Default-deny is the load-bearing choice here. A policy that lists what to remove leaks every field the provider adds after you wrote it; a policy that lists what to keep fails closed, and the failure is a missing field in a test rather than a customer address in a public repository.
Step 3: Re-sign the redacted body with a test key
The captured X-Webhook-Signature covered the original bytes. Once a single character changes, that header is dead weight, and a fixture that ships it will either be rejected by your own verification or, worse, tempt someone to bypass verification “because fixtures can’t be signed”. They can. Store the redacted body without any signature, and have the loader sign it with the test secret at the moment of replay — which also solves the timestamp problem, because a signature minted now sits comfortably inside the tolerance window instead of expiring the day after you commit it.
// test/support/fixtures.ts
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { signBody, TEST_SECRET } from "./signing";
export interface Fixture {
name: string;
eventType: string;
body: string; // exact bytes the test will send
providerHeaders: Record<string, string>; // non-signature headers, as captured
}
const DIR = join(__dirname, "../fixtures");
export function loadFixture(name: string): Fixture {
const parsed = JSON.parse(readFileSync(join(DIR, `${name}.json`), "utf8"));
return {
name,
eventType: parsed.event_type,
// Re-stringify ONCE here; this exact string is both signed and sent.
body: JSON.stringify(parsed.body),
providerHeaders: parsed.headers,
};
}
export function allFixtures(): Fixture[] {
return readdirSync(DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => loadFixture(f.replace(/\.json$/, "")));
}
export function asSignedRequest(fixture: Fixture, nowMs = Date.now()) {
const signed = signBody(fixture.body, TEST_SECRET, nowMs);
return {
method: "POST" as const,
headers: { ...fixture.providerHeaders, ...signed.headers },
body: fixture.body,
};
}
Note the ordering of the spread: the freshly minted signature and timestamp overwrite anything left in the captured header set, so a stale x-webhook-signature that survived the capture cannot shadow the real one.
Step 4: Detect fixture drift against live traffic
A fixture is a photograph, and providers redecorate. Six months on, live payloads may carry three new fields, a renamed enum value and a date format that gained a timezone offset, while your suite is still green against the photograph. Drift detection closes that gap: a scheduled job samples recent live captures, checks them against the shape the fixtures assert, and classifies every difference. Additive changes refresh the fixture; breaking changes stop a release and page the integration’s owner.
// tools/drift.ts — run nightly against recent captures
import { pool } from "../src/db";
import { allFixtures } from "../test/support/fixtures";
type Shape = Record<string, string>; // dotted path -> JSON type
function shapeOf(value: unknown, path = "", acc: Shape = {}): Shape {
if (value === null) return { ...acc, [path]: "null" };
if (Array.isArray(value)) return shapeOf(value[0], `${path}[]`, acc);
if (typeof value !== "object") return { ...acc, [path]: typeof value };
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
acc = shapeOf(child, path ? `${path}.${key}` : key, acc);
}
return acc;
}
export async function detectDrift() {
const findings: string[] = [];
for (const fixture of allFixtures()) {
const expected = shapeOf(JSON.parse(fixture.body));
const { rows } = await pool.query(
`SELECT raw_body FROM webhook_captures
WHERE received_at > now() - interval '24 hours'
AND raw_body::json ->> 'type' = $1
LIMIT 50`,
[fixture.eventType],
);
for (const row of rows) {
const live = shapeOf(JSON.parse(row.raw_body.toString("utf8")));
for (const [path, type] of Object.entries(live)) {
if (!(path in expected)) findings.push(`ADDITIVE ${fixture.name}: new ${path} (${type})`);
else if (expected[path] !== type && type !== "null")
findings.push(`BREAKING ${fixture.name}: ${path} was ${expected[path]}, now ${type}`);
}
for (const path of Object.keys(expected)) {
if (!(path in live)) findings.push(`BREAKING ${fixture.name}: ${path} disappeared`);
}
}
}
return [...new Set(findings)];
}
Verification and testing
Two guarantees have to hold before a fixture corpus can be trusted. The first is that no fixture contains anything sensitive; the second is that every fixture still passes the real verification path after redaction and re-signing. Both are assertions, not reviews:
// test/fixtures.test.ts
import { describe, expect, it } from "vitest";
import { allFixtures, asSignedRequest } from "./support/fixtures";
import { verifySignature, TEST_SECRET } from "./support/signing";
const FORBIDDEN = [/[\w.+-]+@[\w-]+\.[\w.]+/, /\b4\d{15}\b/, /whsec_(?!test)/];
describe("fixture corpus", () => {
it("contains no emails, card numbers or live secrets", () => {
for (const fixture of allFixtures()) {
for (const pattern of FORBIDDEN) {
expect(pattern.test(fixture.body), `${fixture.name} matched ${pattern}`).toBe(false);
}
}
});
it("re-signs every fixture so the production verifier accepts it", () => {
for (const fixture of allFixtures()) {
const req = asSignedRequest(fixture);
const result = verifySignature(
Buffer.from(req.body),
req.headers["x-webhook-timestamp"],
req.headers["x-webhook-signature"],
TEST_SECRET,
);
expect(result.ok, `${fixture.name} failed verification`).toBe(true);
}
});
});
Wire detectDrift() into a nightly job that fails on any BREAKING line and opens a low-priority issue for ADDITIVE ones. Because the check reads live captures rather than the provider’s changelog, it catches undocumented changes too — which, in practice, is most of them.
Failure modes and gotchas
- Re-serialising the body between capture and signing. Parsing a fixture to an object and stringifying it in one place, then stringifying again elsewhere, yields two byte sequences and a signature that verifies in neither. Produce the string once in the loader and pass that exact string to both the signer and the request.
- Random pseudonyms breaking event correlation. Replacing identifiers with a fresh UUID per run destroys the relationship between a
customer.createdfixture and theinvoice.paidfixture that references it. A keyed hash keeps the join intact while removing the real value — and rotating that key deliberately re-anonymises the whole corpus. - A fixture corpus of one happy path. Twenty variations of a successful payment prove almost nothing. Deliberately capture the disputes, the partial refunds, the zero-amount events and the payloads with null optional objects, because those are the branches that break, and pair them with the duplicate and out-of-order scenarios your mock sender can inject.
- Fixtures that silently replace sandbox testing. A redacted fixture cannot tell you that your endpoint registration is wrong or that the provider stopped emitting an event type at all. Keep a scheduled run against the provider’s sandbox so absence of an event is still detectable.
- Unbounded capture retention. A capture table that keeps raw bodies forever becomes an unreviewed archive of customer data with production-grade sensitivity and test-grade access control. Set a retention window, enforce it with a scheduled delete, and promote only redacted copies into the repository.
Frequently Asked Questions
Should fixtures be committed to the repository or fetched from storage at test time?
Commit them. A fixture in version control gets reviewed when it changes, works with no network access in CI, and turns a provider's payload change into a visible diff instead of a silent update underneath the suite. Payloads too large to review comfortably — bulk exports, base64 documents — are the exception: keep those in object storage, pin them by content hash, and commit the hash.
How do we get a fixture for an event type production has never emitted?
Capture it from the provider's test mode and record that provenance in the fixture file so nobody mistakes it for production-grade evidence. A sandbox-captured fixture is missing exactly what makes a capture valuable: the unusual unicode, the populated optional objects, the addresses that run to twelve lines. Treat it as a placeholder and replace it with a real capture the first time production produces one.
What happens to the corpus when the redaction key is rotated?
Every pseudonym changes, so a corpus regenerated under a new key is internally consistent but no longer joins against fixtures generated under the old one. Regenerate the whole corpus in a single commit, or you end up with files that quietly disagree about which customer is which. Because regeneration needs the original captures, rotate before the capture retention window expires — otherwise the only path back is recapturing from live traffic.
Is one fixture per event type enough for drift detection to mean anything?
No, because the comparison can only reason about fields the sample happens to contain. An event type with optional objects has several legitimate shapes, and a live payload that omits one gets reported as a vanished required field until the corpus covers that variant. Keep two or three variants per type — fully populated, minimal, and whatever edge case last caused an incident — and read the classifier's output as a queue to triage rather than a verdict.
Do the captured headers need redacting as well as the body?
Yes. Headers carry the provider signature, sometimes an authorization value from a shared endpoint, and occasionally an account identifier as sensitive as anything in the payload. Keep the names and their casing exactly as captured, since that is part of what the fixture documents, and strip or pseudonymise the values that carry secrets or identity.
Do timestamps inside the payload need rewriting at load time, or only the signature timestamp?
Both, whenever the handler reasons about how old an event is. A frozen created_at slowly ages past any staleness cutoff and the test starts failing on a date nobody chose, months after the change that caused it. Shift the payload's temporal fields relative to the current time in the loader, preserving the intervals between them, in the same place the body is re-signed.
Related
- Building a mock webhook server for integration tests — the harness that replays these fixtures.
- Testing against provider sandbox environments — the checks a frozen fixture cannot perform.
- Inspecting and replaying webhook deliveries — the delivery store fixtures are promoted from.
- Webhook mocking and sandbox environments — how fixtures fit alongside the other test levels.