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.

Consumer-driven contract verification flow A consumer records its expectations as a contract, publishes it to a broker, and the provider verifies its emitted payloads against that contract in CI before release. Consumer records expectations Contract broker versioned schema Provider CI verifies payloads publish fetch Verification pass: release fail: block build assert shape contract
Consumer-driven flow: the consumer records its expectations as a contract and publishes it to a broker; the provider fetches and verifies its emitted payloads against that contract in CI, blocking the build on any drift.

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

For the full producer/consumer walkthrough with a published broker and provider verification, see consumer-driven contract tests for webhooks.