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-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
- 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.
Related
- Simulating webhook traffic spikes — stress a contract once it is locked.
- Debugging failed webhook deliveries — validate captured traffic against contracts.
- Webhook contract testing — the parent guide on contract testing approaches.