Verifying Provider Contracts in CI with Pact
Providers break consumers in production not because of bad code, but because no gate stopped the breaking change from shipping. The symptom is an unverified provider merge that reaches production while a consumer’s pact — already published to the broker — records an interaction the new provider code no longer satisfies. This guide is part of Consumer-Driven Contracts with Pact and covers exactly how to close that gap: a fully annotated TypeScript provider verifier, the can-i-deploy gate, and a complete GitHub Actions workflow.
Symptom
The failure surfaces in one of three ways:
- Runtime deserialization error in production. The consumer calls a provider endpoint that no longer returns a field the consumer reads. The error arrives as
TypeError: Cannot read properties of undefinedor an unexpectednullcast, not as a test failure. - Consumer integration test failure after provider deploys. The consumer test suite runs against a mock generated from an old pact. The real provider is already ahead of it, and the mismatch is invisible until the consumer’s next CI run.
- Pact Broker verification matrix shows a red cell. The broker knows the consumer published a pact but the provider never published a verification result for it, so
can-i-deployreturns “Computer says no” for the consumer trying to deploy.
All three root causes are the same: the provider’s CI pipeline has no step that runs verification and no gate that checks compatibility before deploy.
Root Cause
Pact’s consumer–provider loop has two halves. The consumer half runs automatically whenever a developer writes a Pact test: a pact file is generated, published to the broker, and a webhook can trigger the provider. The provider half is the part teams skip.
Without a provider verification step in CI:
- No verification result is ever published to the broker.
can-i-deployfinds an empty cell in the matrix and either exits with “unknown” or, if the gate is absent, never runs at all.- The provider deploys without any check against what consumers depend on.
- Consumers discover the break in production.
The fix is not complicated — the Pact JS 12 Verifier API does the heavy lifting — but it requires four things wired together correctly: provider states, version/branch metadata, result publishing, and a hard gate.
Step-by-Step Fix
Step 1 — Write the Provider Verifier with Provider States
The Verifier from @pact-foundation/pact drives everything. The critical options are providerVersion, providerVersionBranch, publishVerificationResult, consumerVersionSelectors, and stateHandlers. Every given() string from every consumer pact must have a matching entry in stateHandlers; if any is missing, those interactions fail with the response the clean test environment happens to return.
// src/__tests__/provider.pact.test.ts
import { Verifier, VerifierOptions } from '@pact-foundation/pact';
import { startTestServer } from '../testServer'; // your real app bound to an ephemeral port
describe('user-api provider verification', () => {
let baseUrl: string;
let stopServer: () => Promise<void>;
beforeAll(async () => {
// Start the real provider (not a mock) on an available port
const server = await startTestServer();
baseUrl = server.url; // e.g. http://localhost:54321
stopServer = server.stop;
});
afterAll(async () => { await stopServer(); });
it('satisfies all consumer contracts', async () => {
const opts: VerifierOptions = {
// --- Broker connection ---
pactBrokerUrl: process.env.PACT_BROKER_URL!,
pactBrokerToken: process.env.PACT_BROKER_TOKEN!, // or username/password
// --- Provider identity ---
provider: 'user-api', // must match consumer's provider name exactly
providerBaseUrl: baseUrl,
// --- Version tagging (always use commit SHA + branch) ---
providerVersion: process.env.GITHUB_SHA!, // immutable, unique per build
providerVersionBranch: process.env.GITHUB_REF_NAME!, // e.g. "main" or "feature/add-roles"
// --- Result publishing (CI only) ---
publishVerificationResult: process.env.CI === 'true',
// --- Which consumer pacts to verify ---
consumerVersionSelectors: [
{ mainBranch: true }, // latest pact from the consumer's main branch
{ deployedOrReleased: true }, // pact for whatever is live in any environment
],
// --- Optional: enable pending and WIP pacts (see Edge Cases) ---
enablePending: true,
includeWipPactsSince: '2026-01-01',
// --- Provider state endpoint ---
// Pact JS 12 calls POST /pact/provider-states on your running server;
// wire a middleware route to handle it, or use stateHandlers below.
// stateHandlers run in-process and are simpler for most setups.
stateHandlers: {
// Key must be byte-for-byte identical to the consumer's given() string
'user 123 exists': async () => {
await db.users.upsert({ id: 123, name: 'Alice', roles: ['admin'] });
// Return nothing; the verifier proceeds to replay the interaction
},
'user 123 does not exist': async () => {
await db.users.deleteById(123);
},
'order 42 exists and is pending': async () => {
await db.orders.upsert({ id: 42, status: 'pending', userId: 123 });
},
// Add one entry per distinct given() used across ALL consumer pacts
},
};
return new Verifier(opts).verifyProvider();
// Non-zero exit on any mismatch; Jest propagates the failure
});
});
Why this works. The Verifier contacts the broker, resolves the consumerVersionSelectors to a concrete list of pacts, then for each interaction: calls the matching stateHandlers entry, replays the recorded HTTP request against providerBaseUrl, and diffs the actual response against the recorded expected response using Pact’s matcher rules. The diff is structural, not literal — integer() and string() matchers from the consumer test are embedded in the pact JSON and respected here.
Set publishVerificationResult: process.env.CI === 'true' rather than a hard true so local runs never write spurious results to the broker. A result published from localhost with your local git SHA would pollute the matrix and cause can-i-deploy to behave unpredictably for the same version that CI later verifies.
Step 2 — Tag Provider Versions with Branch Metadata
Branch tagging is not optional. Without providerVersionBranch, the deployedOrReleased selector on the consumer side cannot resolve which provider is live, and can-i-deploy falls back to checking the absolute latest verification — which may be from a different branch.
The --providerVersionBranch flag is the equivalent of --branch on the consumer publish side. Both sides must tag with branch metadata for the broker’s selectors to work:
# What the consumer publishes (in the consumer CI pipeline)
npx pact-broker publish ./pacts \
--consumer-app-version "$GITHUB_SHA" \
--branch "$GITHUB_REF_NAME" \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"
# What the provider publishes (handled by Verifier options above;
# shown here as a standalone CLI call for reference only — do not run both)
npx pact-broker publish-provider-contract \
--provider user-api \
--provider-app-version "$GITHUB_SHA" \
--branch "$GITHUB_REF_NAME" \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"
In practice providerVersionBranch in the VerifierOptions handles publishing automatically when publishVerificationResult is true — you do not need the standalone CLI call.
Step 3 — Wire Verification into GitHub Actions
The provider verification job sits between the build/unit-test job and the deploy job. It must complete before can-i-deploy runs and before any artifact is pushed to an environment.
# .github/workflows/provider.yml
name: Provider CI
on:
push:
branches: ["**"]
# Also triggered by a Pact Broker webhook when a consumer publishes a new pact:
# configure the webhook to POST to your GitHub Actions workflow_dispatch endpoint
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- name: Unit and integration tests
run: npm test -- --testPathIgnorePatterns='pact'
- name: Start test database
run: docker compose up -d db
# Wait for it before verification runs
- name: Provider contract verification
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF_NAME: ${{ github.ref_name }}
CI: "true"
run: npm run test:pact:provider
# Fails the job if any consumer interaction mismatches
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- name: Can I deploy to production?
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
npx pact-broker can-i-deploy \
--pacticipant user-api \
--version "${{ github.sha }}" \
--to-environment production \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN" \
--retry-while-unknown 12 \
--retry-interval 10
# exits non-zero if incompatible or still verifying
- name: Deploy
run: ./scripts/deploy.sh production
- name: Record deployment
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
npx pact-broker record-deployment \
--pacticipant user-api \
--version "${{ github.sha }}" \
--environment production \
--broker-base-url "$PACT_BROKER_URL" \
--broker-token "$PACT_BROKER_TOKEN"
# record-deployment updates the broker's environment model so
# the next can-i-deploy check for any other service uses the
# correct version of user-api as the "deployed" baseline
Why record-deployment matters. Without it, deployedOrReleased on the consumer selector always resolves to nothing or to a stale version. Every time a provider or consumer deploys, it must record the deployment so the broker’s environment model reflects what is actually live. The pattern is the same across all services — broker-centric gating is how Contract Testing for Microservices achieves independent deployability at scale.
Before / After
Before — no provider verification gate:
# Old provider.yml — verification absent
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm ci
- run: npm test # unit tests only, no pact verification
- run: ./scripts/deploy.sh production # deploys unconditionally
After — verification and can-i-deploy in place:
# New provider.yml — two-job pipeline
jobs:
test:
steps:
- run: npm run test:pact:provider # verifies all consumer pacts, publishes results
deploy:
needs: test
steps:
- run: npx pact-broker can-i-deploy ... # gates on broker matrix
- run: ./scripts/deploy.sh production
- run: npx pact-broker record-deployment ...
The difference is two steps and one new test file. The pact-broker CLI is already available through @pact-foundation/pact-cli (bundled with @pact-foundation/pact v12); no additional dependency is required. For teams choosing between self-hosted Pact Broker and PactFlow, see Pact Broker vs PactFlow for small teams.
Verification — Reading the CI Log
A passing provider verification and can-i-deploy gate produce the following output in the GitHub Actions log:
Verifying a pact between web-frontend and user-api
Given user 123 exists
a request for user 123
with GET /api/v1/users/123
returns a response which
has status code 200 (OK)
has a matching body (OK)
includes headers
"Content-Type" with value "application/json" (OK)
Given order 42 exists and is pending
a request for order 42
with GET /api/v1/orders/42
returns a response which
has status code 200 (OK)
has a matching body (OK)
2 interactions, 0 failures
INFO: Published verification result for user-api version a3f8c01 on branch main
Then the can-i-deploy step:
Checking if user-api version a3f8c01 can be deployed to production
CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS?
---------------|-----------|-----------|-----------|--------
web-frontend | b9e2d44 | user-api | a3f8c01 | true
Computer says yes \o/
All required verification results are published and successful.
A mismatch looks like:
Given user 123 exists
a request for user 123
with GET /api/v1/users/123
returns a response which
has a matching body (FAILED)
Failures:
1) user-api - Upon receiving 'a request for user 123'
with GET /api/v1/users/123
the following mismatches occurred:
* $.roles -> Expected an Array (like [String]) but received String
The exact path ($.roles) tells you which field changed. Fix the provider code or update the consumer’s matcher, then re-run.
Edge Cases and Caveats
Pending pacts block the provider pipeline until acknowledged. When a new consumer adds its first pact, the broker marks it pending. If enablePending is false (the default), the verifier fails immediately because there are no prior results — even though the provider has never committed to that consumer’s contract. Set enablePending: true so new consumer contracts are verified but treated as non-blocking warnings until the first successful verification is published.
WIP pacts surface breaking changes early. includeWipPactsSince pulls the latest pact from each consumer feature branch into the verification run as a pending interaction. This gives the provider team advance warning before those branches merge to main. Keep the date recent (within the last quarter); older dates pull in abandoned branches and slow the run.
State handlers that share a database must be idempotent. If the beforeAll does not reset state between interactions, a handler that inserts a user may find it already present from a previous interaction and fail on a unique-constraint violation. Use upsert semantics or run a truncation/reset in a beforeEach equivalent. The Verifier calls each stateHandlers entry immediately before the interaction it applies to, so ordering is not the issue — idempotency is.
Frequently Asked Questions
Why does provider verification have to run in CI rather than locally?
Only a CI run can reliably publish results back to the broker with the correct version and branch metadata. Local runs with publishVerificationResult enabled can corrupt the verification matrix because a developer’s SHA may not match the version the broker tracks for that branch.
What is a provider state and why is it required?
A provider state is the named precondition recorded in a consumer’s given() clause, for example ‘order 42 exists and is pending’. The provider must register a stateHandlers function for that exact string and use it to seed the database or stub a dependency before the interaction replays. Without it the provider returns a 404 or wrong data and the verification fails.
What is the difference between consumerVersionSelectors mainBranch and deployedOrReleased?
mainBranch resolves to the latest pact published from the consumer’s default branch, which protects the next release. deployedOrReleased resolves to the pact for whichever consumer version is currently running in the target environment, which protects production. You normally use both.
Can I run can-i-deploy before verification results are published?
No. can-i-deploy queries the verification matrix; if no result exists for the required version pair the tool reports the result as unknown and exits non-zero by default. Use --retry-while-unknown to poll for a short window while an in-flight verification finishes.
What are pending pacts and when should I enable them?
A pact is pending when the provider has never published a verification result for it. When enablePending is true the verifier still runs those interactions but treats failures as warnings rather than blocking errors. This lets new consumers add contracts without immediately blocking the provider’s pipeline.
What are WIP pacts?
WIP (work-in-progress) pacts are the latest unverified pact for each consumer branch. Setting includeWipPactsSince to a date pulls them into the verification run as pending interactions so the provider gets early feedback before those branches merge.