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-by-Step Implementation

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));
      });
  });
});

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"

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();
  });
});

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"

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.

Failure Modes and Gotchas