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.

The Contract Surface: What the Consumer Actually Depends On

A payload contract is not the provider’s payload. It is the projection of that payload onto the fields your handler reads, and getting that projection right is the difference between a suite that catches real breaks and one that cries wolf every time the provider ships a feature. Four properties are worth distinguishing, because each one fails differently and only three of them are reachable by a schema at all. Presence is whether the key is there; its failure is a TypeError on the first property access, usually inside a queue worker where nobody sees the stack trace. Type is whether amount_cents is a JSON number or the string "4200"; its failure is a silent coercion that stores NaN or concatenates instead of adding. Domain is enum membership, string format, and numeric range; its failure is a value your switch has no branch for, which in most codebases means the event is quietly dropped by the default case. Semantics is what the value means — and no schema keyword can see it.

That fourth axis is the one worth internalising before you write a line of schema. If a provider changes amount_cents from gross to net, the field is still present, still an integer, still non-negative, and still matches every pattern you wrote. Every contract test stays green while your ledger drifts by the tax amount on every invoice. Contract testing buys you presence, type, and domain cheaply and completely; semantics is covered by reconciliation checks and business invariants — a nightly job that asserts the sum of your recorded amounts matches the provider’s own reported totals — and it is worth writing that down explicitly so nobody mistakes a green contract suite for proof that the numbers are right.

The second decision is how much of the payload to pin. A mature provider’s invoice.paid body carries sixty to ninety fields; a typical handler reads six. Deriving the schema from the whole captured payload feels thorough and is actively harmful: every one of those eighty-odd unread fields becomes a tripwire that fires on changes you do not care about, and after the third false failure someone adds --update-snapshots to CI and the suite stops meaning anything. Derive the contract from the handler’s field reads instead. In practice that means grepping for every event.data. access, or better, funnelling parsing through a single typed decoder so the decoder’s type is the enumeration of what you depend on. The rule of thumb that survives contact with real codebases: if removing a field would not change any observable behaviour of your service, it does not belong in the contract.

The contract surface of one captured delivery Six fields of a captured invoice.paid payload, four of them tagged with the schema rule that pins them and two marked as read by nobody, with a note that a change of meaning satisfies every rule. one captured invoice.paid body, projected onto the handler's field reads "type": "invoice.paid" const - a renamed event type fails "id": "evt_71a3c9" pattern - an id format migration "amount_cents": 4200 integer, minimum 0 - a string flip "status": "paid" enum - an unmodelled new state "customer_locale": "en-GB" no handler reads it, so no rule "legacy_ref": null null or absent, both are fine A gross-to-net change to amount_cents keeps every rule above green. Meaning is the axis no schema keyword can reach - reconcile it instead.
Four rules cover presence, type and domain for the fields that matter; the two unasserted rows are a deliberate choice, and the band at the bottom is the class of break you will never catch this way.

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.

Read the schema as four independent tripwires rather than one document. Each keyword catches a distinct class of upstream change, and dropping any one of them opens a specific blind spot — a schema with required but no pattern will happily accept an ID format migration that breaks your foreign keys.

Anatomy of a contract schema and what each rule catches The required list, the closed additionalProperties setting, the event id pattern and the integer amount constraint each map to a different kind of producer change they will fail on. contract.schema.ts what that rule catches required: [type, id, data] a removed or renamed field additionalProperties: false a quietly added field id: pattern ^evt_ an identifier format migration amount_cents: integer, min 0 a string-for-number type flip
Every keyword you leave out is a change you have decided not to be told about — the closed object is the one that turns silence into a failing build.

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.

Which of the two patterns you reach for is decided almost entirely by who owns the producer, because that determines whose build can be made to fail. A schema over captured fixtures is the only option against a third party; a message pact is strictly stronger when the producer’s CI can be made to run your expectations.

Choosing a contract mechanism by producer ownership Three ownership situations — a third-party producer, another team in the same organisation, and shipping both sides yourself — each lead to a different contract mechanism and a different build that fails first. Who owns the producer? a third-party SaaS with no contract tooling another team in your org with its own pipeline you ship producer and consumer together JSON Schema over captured fixtures consumer-driven message pact via broker pact plus schema in a single suite drift fails your own CI drift fails their build drift fails the pull request
The bottom row is the real difference: only a published pact moves the failure onto the build that introduced the change.

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

Because the suite needs neither a network nor a provider, it slots in next to the rest of your offline coverage under webhook mocking and sandbox environments: a mock webhook server for integration tests proves the handler survives real HTTP and duplicate deliveries, while the contract suite proves the body it received still has the shape the handler reads. Run them in the same job — a passing contract with a handler that cannot parse the request is not a green build.

Evolving a Contract Without a Flag Day

A contract that can only ever be tightened becomes a liability the first time the producer needs to ship a legitimate change. The mechanism that keeps both sides moving is expand-and-contract, and the only genuinely hard part is choosing how long the expanded state lasts. In the expand phase the new schema is added alongside the old one and both shapes validate; the producer starts emitting the new shape while continuing to populate the old field; consumers switch their reads over; and only in the contract phase does the old shape become invalid. Each phase is a separate deploy on each side, which is precisely the point — no single release has to land simultaneously in two repositories.

The window between expand and contract is where teams guess badly. It is not a matter of taste; it is arithmetic over how long an old-shaped payload can still be in flight. A delivery first attempted just before the cutover may still be retried for the length of the sender’s retry horizon — commonly 24 to 72 hours for a mature provider using exponential backoff. If your operations team can re-inject a delivery from the dead-letter queue, the oldest payload the handler can ever see is as old as your dead-letter retention, commonly 14 days. Add one release cycle so a consumer that missed the migration has a chance to catch up, and the arithmetic for a typical stack is 72 hours plus 14 days plus 7 days, or roughly 24 days — which is why 30 days is the default worth writing into the deprecation note rather than the 3 days someone will propose in review.

The expand-and-contract window for one payload version Four dated stages across a month - schema expansion, dual emission, consumer cutover and finally forbidding the old shape - with a panel showing the retry horizon, dead-letter retention and release cadence that set the window length. one payload version migration, day 0 to day 30 expand v2 schema added, old shape still valid dual emit producer sends both shapes per event cut over handler reads v2, v1 still accepted contract v1 rejected once replay can't reach it day 0 day 1 day 7 day 30 why day 30 and not day 3 72 h sender retry horizon plus 14 d dead-letter retention plus one 7 d release cadence
The deprecation window is not a preference: it is the retry horizon plus replay retention plus one release, and shortening it means a replayed delivery fails validation weeks after the migration looked complete.

Running two schemas at once needs a dispatch layer, and the shape of that layer matters more than it looks. Keying only on the event type forces you to encode version differences inside one increasingly oneOf-heavy schema, which produces validation errors so vague they are useless in an incident. Keying on the pair of event type and payload version keeps each schema small and each error message specific, and it gives you the one behaviour every migration needs: an explicit, separately-metered outcome for a version you have never seen, rather than a generic validation failure that looks identical to a corrupt payload.

// contracts/registry.ts — one validator per (event type, payload version)
import Ajv, { ValidateFunction } from "ajv";
import { orderCreatedV1 } from "./order_created.v1.schema";
import { orderCreatedV2 } from "./order_created.v2.schema";

const ajv = new Ajv({ allErrors: true, strict: false });

const registry = new Map<string, ValidateFunction>([
  ["order.created:1", ajv.compile(orderCreatedV1)],
  ["order.created:2", ajv.compile(orderCreatedV2)],
]);

export type ContractResult =
  | { ok: true; version: number }
  | { ok: false; reason: "unknown_version" | "schema_violation"; detail: string };

export function checkContract(event: { type: string; version?: number }): ContractResult {
  // Payloads that predate versioning are v1 by definition, never "unknown".
  const version = event.version ?? 1;
  const validate = registry.get(`${event.type}:${version}`);
  if (!validate) {
    return { ok: false, reason: "unknown_version", detail: `${event.type} v${version}` };
  }
  if (!validate(event)) {
    return { ok: false, reason: "schema_violation", detail: ajv.errorsText(validate.errors) };
  }
  return { ok: true, version };
}

The unknown_version branch is the early-warning system: it fires the moment a producer starts emitting a version you have not modelled, before a single field-level assertion has a chance to be wrong. Alert on it separately from schema_violation, because the two demand different responses — an unknown version means someone shipped ahead of you, while a schema violation means someone shipped something different from what they said.

Producer change Fails a closed schema Breaks the handler Right response
New optional field added Yes No Widen the schema in the next PR; no urgency
Existing field made nullable Only if type excludes null Usually yes Treat as breaking; expand-and-contract
Enum gains a member Only if enum is pinned Yes, if the switch has no default Add a default branch first, then widen the enum
Field renamed Yes Yes Dual-emit both names for the full window
Numeric encoding changed to string Yes Yes, silently Reject at the boundary; never coerce in the handler

Fixture Drift and the Half-Life of a Captured Payload

A contract test is only as truthful as the payload it runs against, and fixtures rot. The provider ships a feature and a new enum value appears; a field that was always populated starts arriving null for customers in a new region; an internal identifier changes format for accounts created after a migration. None of this touches your repository, so the suite stays green while production starts failing — the single most common way a contract-tested integration still breaks, and the hardest to diagnose because CI is telling you everything is fine.

The fix is to close the loop with production traffic. Run the same compiled validator against live deliveries and emit the result as a metric rather than acting on it: a counter labelled by event type, schema version, and the failing keyword. This is shadow validation, and its cost is small enough to make sampling debates unnecessary — a compiled Ajv validator over a 4 KB payload runs in roughly 10 to 40 microseconds, so at 500 deliveries per second you are spending well under 2% of a single core to validate everything. Sample only if you are an order of magnitude above that, and if you do, sample by event type rather than uniformly, or the rare event types you understand least will be the ones you never observe.

Thresholds matter because provider rollouts are gradual. A change deployed to 1% of the provider’s fleet shows up as a 1% violation rate that looks exactly like noise on a dashboard scaled for outages. Alert on any sustained violation rate above 0.1% over 15 minutes for a given event type, which is low enough to catch a canary rollout on the day it starts and high enough to survive the occasional genuinely malformed payload. Pair it with a freshness rule in the suite itself: stamp every fixture with the timestamp it was captured, and fail — not warn — when the newest fixture for an event type is more than 90 days old. It is a deliberately annoying test, and the annoyance is the mechanism: it forces someone to look at a real recent delivery four times a year.

The lifecycle of a committed fixture A captured delivery becomes a trusted fixture, ages past thirty and ninety days, and is either quarantined when a live payload violates the schema or recaptured and committed again. redact and re-sign after 30 days after 90 days Captured from live traffic Trusted under 30 days old Aging 30 to 90 days old Stale over 90 days old Quarantined shape no longer matches live Recaptured redacted and re-signed a live delivery fails stale: the suite fails triage commit the fresh delivery as the new fixture
Every fixture is on a clock: the only two exits from aging are a recapture you scheduled or a quarantine an incident forced on you.
Drift signal Where it shows first Threshold worth paging on First action
Unknown enum member Shadow validator, one event type 0.1% of that type over 15 min Add a default branch, then widen the enum
Field newly nullable Handler exceptions, not the schema Any occurrence in a required field Capture the payload, widen type, add fixture
Unknown payload version unknown_version counter Any occurrence Model the new version before it reaches 100%
Fixture older than 90 days The suite’s freshness assertion Every run once tripped Capture a current delivery and replace it
Violation rate rising linearly Shadow validator, all types 1% and climbing over a day Assume a staged provider rollout; escalate now

Contracts You Do Not Own

Everything above assumes you can eventually make somebody change something. Against a third-party provider you cannot fail their build, cannot review their diff, and frequently cannot get a straight answer about when a change ships. What you can control is detection latency — the interval between the first payload that violates your contract and the moment a human knows. Every tactic here is about shrinking that number.

Start by pinning the provider’s API version wherever one is offered, whether that is an account-level setting or a per-request version header. Pinning converts an ambient, unannounced change into a deliberate upgrade you schedule. It is not complete protection: providers routinely ship additive and bug-fix changes outside the versioning scheme, and a pinned version can be retired from under you. But it removes the largest category of surprise, and it makes the remaining surprises smaller. Next, record the provider’s version identifier as a dimension on every delivery you store. Without it, a change rolling out to 5% of the provider’s fleet presents as intermittent failures that correlate with nothing, and the usual misdiagnosis is a flaky network or one bad host on your own side; with it, the violation metric splits cleanly by version and the shape of the problem is obvious in one query.

When you do escalate, evidence decides how fast you get a response. A ticket saying “your payloads changed” gets triaged into a queue; a ticket with three delivery identifiers, exact timestamps, the JSON pointer of the offending field, and the before-and-after values usually reaches an engineer the same day. Keep the shadow validator’s error detail structured enough to paste. Finally, accept that the provider’s documentation is not the contract — the traffic is. When the docs promise a string and the wire delivers null, model the wire, add a comment naming the support ticket, and revisit it when they reply. A schema that encodes the documentation instead of reality is a schema that fails on every real delivery while remaining perfectly defensible in review.

When a Contract Breaks in Production

The alert fires at 09:40 and the shadow validator says 100% of charge.refunded deliveries are violating the schema. Work the problem in this order, because the first question determines everything after it.

  1. Read the failing keyword before anything else. An additionalProperties failure means the provider added something and nothing of yours is broken — it is a Monday-morning schema widening, not an incident. A required, type, or enum failure means a field your handler genuinely reads has changed, and the clock is now running against the sender’s retry horizon.
  2. Decide the disposition of the affected traffic. Rejecting is safe only if you will ship a fix inside the retry window, because a rejected delivery that exhausts its attempts is gone. Accepting and routing the payload to a dead-letter queue for later replay is the better default: it preserves the event, keeps the provider’s delivery success rate healthy, and turns a deadline into a backlog. Accepting and degrading — processing the fields you can still read and flagging the record — is appropriate only when partial processing is genuinely safe, which for anything touching money it is not.
  3. Narrow the relaxation, never disable the check. The temptation is a feature flag that turns off validation. Instead, relax the single keyword for the single event type, in code, with an expiry date in the comment and a linked issue. A global bypass gets committed in ten minutes and removed in ten months.
  4. Ship the schema change, the handler change, and the new fixture in one pull request. The fixture is the durable part: without a captured copy of the payload that broke you, the same regression returns the next time someone refactors the parser. This is also the moment to add whichever assertion would have caught it earlier — usually an enum you had left as a bare string.
  5. Measure detection latency in the retrospective. First bad delivery to alert, in minutes. If it was hours, the shadow validator is sampling too thinly or the threshold is too high. If it was days, you learned about it from a customer, and the shadow validator is not deployed at all.

Worked through with real numbers, the common case looks like this: a provider adds a field on 100% of one event type, a closed schema rejects, the handler returns 400, and the provider retries with backoff eight times over 72 hours. At 5,000 events per day for that type, roughly 12,000 deliveries accumulate as pending retries before anyone finishes lunch. Widening additionalProperties for that one event type and deploying takes ten minutes, and the retry backlog drains on its own — which is the whole argument for a closed schema being loud rather than a permissive one being quiet.

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.

Frequently Asked Questions

Should the schema live in the consumer's repository or the producer's?

In the consumer's, because it encodes what that particular consumer needs — two consumers of the same event legitimately depend on different subsets, and merging them into one producer-owned schema forces every consumer to inherit the strictest one. The producer may publish its own schema describing what it emits; that is a different artefact and the two should be allowed to disagree, since the gap between them is exactly the set of fields nobody consumes.

Won't a closed schema turn every provider release into a red build?

Only if you pin fields you never read. A schema derived from the handler's actual field accesses is closed over a small object, so an additive change fails one assertion with an obvious message and is resolved by a one-line widening. If your build genuinely goes red every sprint, that is a signal the schema was generated from the full captured payload rather than from what the code consumes.

Can a contract test replace runtime validation in the handler?

No, and conflating them is how teams end up with neither. The contract test runs in CI against fixtures and answers "did the agreement change"; runtime validation runs on every live delivery and answers "is this specific payload safe to process". Compile the same schema into both so they cannot diverge, but keep both call sites — CI never sees the payload that arrives at 3 a.m.

How many fixtures per event type are actually enough?

Three is a reasonable working minimum: a fully populated payload, a minimal one with every optional field absent, and one representing whatever variant your provider treats differently — a different currency, a zero amount, a refunded state. Beyond that, add a fixture only when it captures a shape that previously broke you, so the corpus grows in response to real incidents rather than to imagination.

How do we pin a contract for an event we have never received?

Write the schema from the provider's documentation, mark it explicitly as unverified in a comment, and treat the first real delivery as the moment the contract becomes real. Until then, keep the rules loose — presence and type only, no enums or patterns — because a documentation-derived schema that is too strict will reject the first genuine payload and cost you an outage on launch day.

Is a Pact broker worth the operational cost for two internal services?

Usually not, if both services are released from the same pipeline and can be tested together. The broker earns its keep when producer and consumer deploy independently, because that is when you need a durable record of which version pairs were verified. With a single pipeline, a schema shared as a versioned package plus a test on both sides gives you most of the protection with none of the infrastructure.

What breaks first when a contract test and the handler disagree?

The handler, silently — which is why the schema must be compiled from the same artefact the handler parses against rather than maintained beside it. The classic symptom is a suite that validates a field the handler stopped reading two refactors ago while a newly-read field has no rule at all. Regenerating the schema from the decoder type, or at minimum reviewing them together, is what keeps the two honest.