Webhook Contract Testing: Pinning the Payload Shape in CI
Catching a breaking payload change before it ships is the central concern of webhook testing and local development, and contract testing is the cheapest, most deterministic tool for the job. A webhook payload is a contract between a producer you frequently do not control and a consumer you do; when the producer adds a required field, renames an enum, or changes a date format, your handler can keep returning 200 OK while silently writing corrupt state. Contract testing makes that implicit agreement explicit and executable: you encode the exact shape your consumer depends on, then assert against it on every change so a drift fails a build rather than an invoice. Unlike end-to-end tests, contract tests need no network, no tunnel, and no provider sandbox — they run in milliseconds, which is what lets you gate every commit on them.
Pattern 1: JSON Schema Assertions
The lightest-weight pattern asserts each incoming payload against a JSON Schema that captures the fields, types, and required keys your handler actually reads. This works even when the producer is a third party with no contract-testing tooling: you derive the schema from a real captured payload, commit it, and validate every fixture against it. The schema is the contract, and it pairs directly with the producer-side event schema design and payload versioning discipline.
// contract.schema.ts — the shape the consumer depends on
export const orderCreatedSchema = {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
required: ["type", "id", "data"],
additionalProperties: false, // a new top-level field fails the test loudly
properties: {
type: { const: "order.created" },
id: { type: "string", pattern: "^evt_" },
data: {
type: "object",
required: ["order_id", "amount_cents", "currency"],
properties: {
order_id: { type: "string" },
amount_cents: { type: "integer", minimum: 0 },
currency: { type: "string", pattern: "^[A-Z]{3}$" },
},
},
},
} as const;
// contract.test.ts — Vitest, runs with no network
import Ajv from "ajv";
import { describe, it, expect } from "vitest";
import { orderCreatedSchema } from "./contract.schema";
import capturedPayload from "./fixtures/order_created.json";
const validate = new Ajv({ allErrors: true }).compile(orderCreatedSchema);
describe("order.created contract", () => {
it("accepts the captured production payload", () => {
const ok = validate(capturedPayload);
expect(ok, JSON.stringify(validate.errors)).toBe(true);
});
it("rejects a payload missing a field the handler reads", () => {
const { currency, ...incomplete } = capturedPayload.data;
const ok = validate({ ...capturedPayload, data: incomplete });
expect(ok).toBe(false); // drift is caught here, not in production
});
});
Setting additionalProperties: false is deliberate: it turns a silent additive change into a loud test failure, forcing a human to decide whether the new field matters. For deliberately permissive consumers, relax it per-object rather than globally.
Pattern 2: Consumer-Driven Pact Contracts
When you control both sides — or the producer is a cooperating internal team — a Pact-style consumer-driven contract is stronger. The consumer writes expectations as code, generating a contract file; the producer’s CI replays that contract against its real serializer and fails if it can no longer satisfy it. This catches drift on the producer’s build, before the change ever reaches a consumer.
// consumer.pact.test.ts — the consumer declares what it needs
import { MessageConsumerPact, synchronousBodyHandler } from "@pact-foundation/pact";
import { like, term } from "@pact-foundation/pact/src/dsl/matchers";
const messagePact = new MessageConsumerPact({
consumer: "billing-service",
provider: "orders-service",
});
describe("orders-service webhook contract", () => {
it("emits an order.created event this consumer can process", () => {
return messagePact
.expectsToReceive("an order.created webhook")
.withContent({
type: "order.created",
id: term({ generate: "evt_123", matcher: "^evt_" }),
data: {
order_id: like("ord_987"),
amount_cents: like(4200),
currency: term({ generate: "USD", matcher: "^[A-Z]{3}$" }),
},
})
.verify(synchronousBodyHandler((event) => {
// the real handler logic the consumer ships
if (typeof event.data.amount_cents !== "number") throw new Error("bad amount");
}));
});
});
The matchers (like, term) assert types and patterns, not exact values, so the contract survives realistic payload variation while still failing on a renamed field or a changed format. The generated contract is published to a broker that the producer verifies against — the flow in the diagram above.
Validation, Signatures, and the Contract Boundary
A contract test verifies shape, not authenticity — the two are complementary and must both run. Contract tests operate on already-verified payloads; they assume the HMAC signature verification gate has run first. Keep these layers ordered in the handler: verify the signature on the raw body, then parse, then assert the contract. Validating shape before authenticity would waste cycles on forged input and risk parsing hostile payloads. Use the captured fixtures from your inspecting and replaying webhook deliveries store as contract test inputs so the schema is grounded in payloads the provider really sent.
CI Gating
Contract tests earn their value only when they block merges. Run them as a required check on every pull request, and run the consumer suite again on a schedule against the latest captured fixtures so a provider’s quiet change surfaces within a day.
# .github/workflows/contract.yml
name: contract-tests
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm run test:contract # vitest contract suite — required check
Failure Modes & Mitigations
| Failure Mode | Root Cause | Mitigation |
|---|---|---|
| Additive field silently ignored | Schema allows unknown properties | Set additionalProperties: false to force a decision on every new field |
| Contract passes but production breaks | Fixtures are hand-written and diverge from real payloads | Source fixtures from captured production deliveries, not from memory |
| Brittle test fails on benign value changes | Contract asserts exact values instead of types | Use type/pattern matchers (like, term) or schema constraints, not literals |
| Drift discovered only at deploy | Contract suite is not a required CI check | Gate merges on the suite; run it on a schedule against fresh fixtures |
| Forged payloads reach the contract layer | Shape asserted before authenticity | Verify the signature on the raw body before parsing and asserting shape |
Debugging Checklist
-
additionalProperties: false(or per-object equivalent) catches added fields - Fixtures are captured real payloads, not hand-authored
- Matchers assert types and patterns, not brittle literal values
- Signature verification runs before shape assertion in the handler
- The contract suite is a required PR check and runs on a schedule
- Each schema is versioned alongside the payload version it describes
For the full producer/consumer walkthrough with a published broker and provider verification, see consumer-driven contract tests for webhooks.
Related
- Consumer-driven contract tests for webhooks — the end-to-end Pact walkthrough.
- Local webhook development with tunnels — exercise contracts against live deliveries.
- Inspecting and replaying webhook deliveries — source real fixtures for your contracts.
- Event Schema Design — the producer side of the payload contract.
- Webhook Testing & Local Development — the full testing pipeline.