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.
Prerequisites
- A TypeScript project on the consumer side with Jest (or your preferred runner) and
@pact-foundation/pactinstalled. - Access to the provider’s webhook-producing code path so it can be invoked in a verification test.
- A running Pact Broker (self-hosted or PactFlow) reachable from CI, with credentials in CI secrets.
- An agreed event type to pin — for example
order.created.v1— ideally derived from your event schema design. - Node 18+ and CI that can run two jobs (consumer publish, provider verify) in sequence.
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.
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.
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 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
- Over-specified matchers. Pinning literal values (exact amount, exact timestamp) turns every benign data variation into a false failure. Use
like,integer, andregexto assert shape, not content. - Provider verifies a fixture, not the producer. If the message provider returns a hand-written object instead of calling the real
buildOrderCreatedEvent, the test passes while production drifts. Always invoke the actual producer code path. - Signature/transport untested. Message pacts validate the body, not the HMAC header. Verify signing separately so a contract-valid payload is still accepted by the receiver’s HMAC-SHA256 verification.
- Skipping can-i-deploy. Publishing pacts without gating deploys gives you documentation, not protection. The gate is what actually blocks an incompatible release.
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.
Related
- Simulating webhook traffic spikes — stress a contract once it is locked.
- Debugging failed webhook deliveries — validate captured traffic against contracts.
- Building a mock webhook server for integration tests — generate the bodies your pact asserts on.
- Webhook contract testing — the parent guide on contract testing approaches.