Consumer-Driven Contract Tests for Webhooks

When a webhook provider quietly renames a field or tightens a type, the consumer only finds out in production — usually as a deserialization error buried in a retry loop. This page extends webhook contract testing with a concrete, Pact-style workflow in TypeScript that catches those breaks before deploy, and it pairs naturally with load testing webhook endpoints once the shape is locked so you stress only contracts you trust. Because webhooks are asynchronous messages rather than request/response calls, we use Pact’s message pact flavor: the consumer declares the message it can handle, and the provider proves it emits exactly that.

Consumer-driven contract verification flow The consumer test writes an expected message to a pact, the pact is published to a broker, and the provider verification replays the message against its real producer code. Consumer test expected message Pact broker versioned contract Provider verify replay + assert publish fetch can-i-deploy gate
The consumer publishes its expected message as a versioned pact; the provider fetches and replays it, and can-i-deploy gates the release on mutual verification.

Prerequisites

Step 1: Model the consumer expectation

Use a MessageConsumerPact to declare the message your handler depends on. Match on types and structure, not literal values, with MatchersV3 — pinning exact values makes the contract brittle and fails on legitimate data variation.

import { MessageConsumerPact, MatchersV3 } from "@pact-foundation/pact";
import path from "path";
import { handleOrderCreated } from "../src/handlers/orderCreated";

const { like, integer, string, regex } = MatchersV3;

const messagePact = new MessageConsumerPact({
  consumer: "orders-consumer",
  provider: "billing-webhooks",
  dir: path.resolve(process.cwd(), "pacts"),
});

describe("order.created.v1 webhook contract", () => {
  it("is handled by the consumer", () => {
    return messagePact
      .expectsToReceive("an order.created.v1 event")
      .withContent({
        id: regex("^evt_[a-z0-9]+$", "evt_abc123"),
        type: string("order.created.v1"),
        data: like({
          amount: integer(4200),
          currency: regex("^[A-Z]{3}$", "USD"),
        }),
      })
      .verify(async (message) => {
        // The real handler must accept the contract message without throwing.
        await handleOrderCreated(JSON.parse(message.contents));
      });
  });
});

The matcher you choose for each field decides what the contract will and will not tell you. A literal value is not a stronger assertion than like — it is a noisier one, failing on every legitimate change in the data while catching nothing extra about the structure. The only field that is genuinely unprotected is one you left out of the pact entirely.

What each Pact matcher choice actually protects Literal values, like, integer, regex and an omitted field compared on what each pins, whether benign data variation breaks the test, and whether a renamed key is caught. Matcher used What it pins Brittle to drift Catches rename literal 4200 the exact value yes yes like(4200) the type only no yes integer(4200) integer, not float no yes regex on a currency code the string format no yes field omitted from the pact nothing at all no no
Rows two through four catch every structural break without a single false alarm, which is why a literal value is a downgrade rather than a stricter test.

Step 2: Generate and publish the pact

Running the test writes pacts/orders-consumer-billing-webhooks.json. Publish it to the broker, tagged with the consumer’s git SHA and branch so verification and deploy gating can find the right version.

npx pact-broker publish ./pacts \
  --consumer-app-version "$GIT_SHA" \
  --branch "$GIT_BRANCH" \
  --broker-base-url "$PACT_BROKER_URL" \
  --broker-token "$PACT_BROKER_TOKEN"

Step 3: Verify the provider against the contract

On the provider side, a MessageProviderPact maps each described message to the actual producer function that builds the webhook body. This is the step that catches a renamed field: if the producer no longer emits data.amount, verification fails here, not in production.

import { MessageProviderPact } from "@pact-foundation/pact";
import { buildOrderCreatedEvent } from "../src/producers/orderCreated";

describe("billing-webhooks provider verification", () => {
  it("honors all consumer contracts", () => {
    const provider = new MessageProviderPact({
      provider: "billing-webhooks",
      messageProviders: {
        "an order.created.v1 event": () =>
          // Invoke the real producer with a representative order.
          Promise.resolve(
            buildOrderCreatedEvent({ amount: 4200, currency: "USD" }),
          ),
      },
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      provider_version: process.env.GIT_SHA,
      publishVerificationResult: true,
    });
    return provider.verify();
  });
});

Step 4: Gate the deploy in CI

Wire can-i-deploy as the last step before release. It returns non-zero unless every consumer pact has a matching, passing provider verification for the versions you intend to ship together.

npx pact-broker can-i-deploy \
  --pacticipant billing-webhooks --version "$GIT_SHA" \
  --to-environment production \
  --broker-base-url "$PACT_BROKER_URL" --broker-token "$PACT_BROKER_TOKEN"

The four steps are not four test files — they are four jobs on a release train that must run in order, because each one consumes the artefact the previous one published. A provider verification that starts before the consumer has published its pact for this branch verifies the previous contract and passes for the wrong reason, so make the dependency explicit in the pipeline rather than relying on wall-clock luck.

The contract release train across one pipeline run Five ordered stages on a single pipeline timeline: the consumer job runs its pact test, publishes the pact tagged with the git SHA, the provider job replays it, can-i-deploy checks the matrix, and only then does the release proceed. one pipeline run, left to right 1. consumer job runs the pact test locally 2. publish pact tagged with the git SHA 3. provider job replays every message 4. can-i-deploy checks the version matrix 5. release both sides verified t plus 0 s t plus 30 s t plus 90 s t plus 120 s t plus 125 s a missing verification for this SHA pair exits non-zero and stops stage 5
Stage 4 is the only stage that can stop a release, and it can only do its job if stages 1 to 3 all ran against the same pair of versions.

Reading a Verification Failure

Provider verification either passes or prints a mismatch report, and the report is more precise than most people give it credit for. Each failure names the interaction description, the JSON path, and the class of mismatch, which is why the description you gave expectsToReceive matters operationally: “a message” tells you nothing at 2 a.m., while “an order.created.v1 event with a refunded line item” points straight at the producer branch that changed.

Failures:

1) Verifying a pact between orders-consumer and billing-webhooks
   Given an order.created.v1 event
   1.1) has a matching body
      $.data.amount -> Expected amount but was missing
      $.data.currency -> Expected "USD" to match "^[A-Z]{3}$" but was "usd"

Three mismatch classes cover almost everything you will see. Missing key means the producer stopped emitting a field the consumer reads, which is the break the whole exercise exists to catch. Type mismatch usually means a serialiser change — a decimal that became a string, or an integer that became a float once someone introduced tax proration. Regex mismatch is the sneaky one, because the field is present and the type is right; a currency code that arrived lowercase or an identifier that lost its prefix will fail here and nowhere else, and it is precisely the class of change that would otherwise reach production untouched by any type system.

One failure mode is not a real break at all: verifying pacts from branches nobody ships. By default a provider can end up fetching every pact ever published, including one from an abandoned experiment that expects a field the producer never had. A broker holding forty feature-branch pacts turns a twenty-second verification into a four-minute one and blocks releases on contracts that do not exist in any running system. Configure the consumer version selectors so the provider verifies the main branch plus whatever is actually deployed or released, and the noise disappears without weakening the gate.

Adding a Field Without Blocking the Provider

The standard objection to consumer-driven contracts is that a consumer can turn the provider’s build red for a change the provider has not agreed to make. That objection is correct about naive setups and solved by pending pacts. When a consumer publishes a contract the provider has never successfully verified, the provider’s build runs it, reports it, and does not fail on it. Once verification passes for the first time, the pact stops being pending, and from that point onward a failure is a genuine regression that fails the build.

The safe order of operations is therefore five steps rather than two. The consumer adds the new expectation and publishes on its branch, where it lands as pending. The provider’s next build shows the new expectation failing in its report while staying green overall. The provider implements the field and its build goes fully green, which flips the pact out of pending automatically. Only then does the consumer merge the code that reads the field, because until the provider ships, the field is not there in production regardless of what the broker says. Finally, can-i-deploy starts enforcing the pairing on both sides.

The cost of pending pacts is a one-cycle blind spot: a contract that has never verified cannot fail the build, so an expectation with a typo in the field name can sit quietly reporting a failure nobody reads. The mitigation is not to disable pending, it is to treat the pending-failure list as a work queue with an owner. A pact that has been pending for more than two sprints is either a feature that died or a field the provider forgot, and both are worth a five-minute conversation rather than a permanent yellow line in the build log.

When One Webhook Has Several Consumers

A single order.created.v1 event is rarely consumed once. As soon as a second and third service subscribe, the provider’s obligation becomes the union of every published pact, and that union is the real contract — the payload it must keep emitting is defined by the most demanding consumer, not by the producer’s own idea of the event. This is a feature, because it makes the cost of a removal visible before the removal happens, but it needs one piece of governance to stay healthy: an expectation may only exist for a field the consumer genuinely reads. A team that pins a field “just in case” has quietly given itself a veto over the producer’s roadmap, and nobody will notice until a deprecation stalls.

The union contract formed by several consumers of one event Three consumer services each publish a pact covering the fields they read, the broker holds the union, one provider job verifies all of them, and the deploy gate checks every consumer and environment pairing. orders-consumer reads amount, currency analytics-consumer reads created_at only fraud-consumer reads customer_id publishes Pact broker union of three pacts is the real contract billing-webhooks verifies all three in one job can-i-deploy 3 consumers, 2 envs six pairings checked removing a field needs all three to drop it first
The provider does not negotiate with three teams; it verifies one union and lets the deploy gate work out which version pairings are safe to ship together.

The deploy gate is where the multiplication becomes visible. With three consumers and two environments, can-i-deploy is asking about six pairings, and it will refuse the release if even one of them has no passing verification for the exact versions involved. That refusal is almost always correct, but it does mean a stale consumer — a service nobody has redeployed in six months — can block a provider release until someone re-runs its pipeline. The fix is to verify against what is deployed or released rather than against every version in the broker, so retired consumer versions age out of the matrix instead of haunting it.

Verification and Testing

Confirm the loop end to end by introducing a deliberate break: rename amount to total in buildOrderCreatedEvent and re-run the provider verification. It must fail with a clear “missing key” mismatch pointing at the offending message, and can-i-deploy must then refuse the release. Revert the rename and both go green. As a fast local smoke check, assert the generated pact contains the matcher you expect:

jq '.messages[0].contents.type' pacts/orders-consumer-billing-webhooks.json
# => "order.created.v1"

Keep the contract honest by also validating real captured deliveries against the same expectations, which dovetails with how you debug production traffic in debugging failed webhook deliveries. A contract that passes against a body no handler ever received is worth very little, so feed the same expectations the payloads produced by your mock webhook server for integration tests and the fixtures collected under webhook mocking and sandbox environments.

Failure Modes and Gotchas

Frequently Asked Questions

Why a message pact instead of the usual HTTP pact for a webhook?

An HTTP pact pins a request/response pair including method, path, status code and headers, which for a webhook are decided by the receiver rather than by the event. A message pact asserts only the payload the producer emits and the consumer can handle, which is the part both sides genuinely share. Using the HTTP flavour here couples the contract to your routing and makes it fail whenever an endpoint moves.

Does the provider need a running service to verify a message pact?

No, and that is the main practical advantage. Message verification calls the producer function directly and inspects what it returns, so there is no port to bind, no database to seed, and no HTTP stack in the loop. Verification typically runs in seconds inside the provider's normal unit-test job.

What happens if two consumers disagree about the same field's type?

The provider cannot satisfy both, and verification fails for whichever expectation does not match the real emitted value — which is the correct outcome, since one of the consumers is wrong about production. Resolve it by looking at a captured delivery rather than at the two pacts; the wire settles the argument, and the losing consumer amends its expectation.

Can pacts replace the fixtures used elsewhere in the test suite?

Partly. The pact file contains an example body generated from your matchers, and it is legitimate to feed that body into handler tests so the two never diverge. What it cannot replace is a captured production payload, because the pact example only ever contains fields somebody thought to declare, and the interesting production breaks live in the fields nobody declared.

How should the broker be secured given it describes internal payloads?

Treat it as an internal service with authenticated read and write access, because a pact enumerates field names, identifier formats and enum values for your events. Use short-lived CI tokens scoped per participant rather than one shared write token, and never publish a pact whose example body was copied verbatim from a production delivery, since the generated examples end up readable by anyone with broker access.

Should contract verification run on the provider's pull requests or only on main?

On pull requests, with the selectors limited to the main branch and deployed versions of each consumer. Verifying on merge only tells you about the break after it is in the trunk, and the whole value of the mechanism is that the failure lands on the change that caused it. Keep the broader nightly verification too, since consumers publish new expectations on their own schedule.