Verifying Provider Contracts in CI with Pact
A published consumer contract is only a promise until a provider proves it can keep it. This guide is for backend and platform engineers who own a provider service and need to replay consumer expectations against real handlers inside CI, gate every deployment with can-i-deploy, and wire broker webhooks so a new consumer contract triggers verification automatically. It uses @pact-foundation/pact v12’s Verifier with Vitest as the host runner (Jest works identically) and assumes the consumer side from contract testing has already published a pact to the Pact Broker. By the end you will have a verification job that fails loudly on real incompatibilities, stays quiet on genuinely pending contracts, and hands a machine-readable release decision to your deployment pipeline instead of a human reading a log.
Root Cause Analysis
Provider verification exists because a consumer’s recorded expectation means nothing until the provider’s current code satisfies it. The failure mode without it is familiar and expensive: a provider team refactors an endpoint, all of their own tests pass, they deploy, and a consumer that depended on the old response shape breaks in production. The provider’s unit tests never saw the consumer’s expectation, so nothing flagged the incompatibility. The break surfaces downstream, in someone else’s service, often hours later, and the debugging session starts from the symptom rather than the cause. Verification moves that discovery left — into CI, before the artifact is ever promoted.
The Verifier replays each interaction the consumer recorded — same method, path, headers, body — against the provider’s running handlers and asserts the real response matches the contract’s matching rules. Two things make this trustworthy. First, the provider has zero knowledge of the consumer’s internals; it only honors the published contract, so verification cannot be gamed by mirroring the consumer’s implementation. Second, the result is posted back to the broker, so can-i-deploy can later answer “is this provider version compatible with everything currently deployed?” with a hard exit code instead of a human guess. The root problem solved is asymmetric knowledge: the provider learns what consumers actually need before shipping, not after. It is the one test in the provider’s suite that is written by another team, and that is precisely what gives it authority over a cross-service boundary that no single team’s tests otherwise touch.
It helps to be precise about what verification does not do. It does not prove the provider is correct in any absolute sense; it proves the provider satisfies the specific interactions consumers chose to record. If a consumer never records an interaction for an endpoint, that endpoint is invisible to the contract and can break freely. Verification is therefore only as strong as the consumer’s coverage of the fields and paths it actually depends on — which is the deliberate design of consumer-driven contracts and the reason the consumer, not the provider, owns the expectation. The provider’s job is narrow and mechanical: reproduce the recorded world, answer the recorded request, and match the recorded rules.
The piece that makes replay deterministic — and the piece teams most often get wrong — is provider state. Each interaction the consumer recorded carries a given(...) description like “an order with id 42 exists.” Before replaying that interaction, the Verifier calls back into the provider to set up exactly that precondition, then issues the recorded request. This decoupling is what keeps verification honest: the consumer declares the world it assumes, and the provider proves it can produce that world and respond correctly within it. If state setup is skipped or mismatched, the provider returns a 404 or empty payload and the contract appears broken even though the real handler is fine. Most verification failures that are not genuine incompatibilities trace back to a state handler that did not seed the fixture the contract named — a typo in the state string, a shared database row a previous interaction mutated, or a handler that returned before its write committed.
Reproducible Setup
Install Pact on the provider and ensure the service can boot in a test process at a known URL.
npm install --save-dev @pact-foundation/pact vitest
Expose a provider-states endpoint (or in-process handlers) so the Verifier can seed the fixtures each interaction’s given(...) declares:
// provider/test-states.ts
import type { Express } from 'express';
import { db } from '../src/db';
export function registerProviderStates(app: Express): void {
app.post('/_pact/provider-states', async (req, res) => {
const { state } = req.body as { state: string };
if (state === 'an order with id 42 exists') {
await db.orders.upsert({ id: 42, total: 99.5, status: 'CONFIRMED' });
}
res.status(200).end();
});
}
Keep this endpoint mounted only in the test build of the provider. It is a privileged mutation hook — anything that can POST to it can rewrite your fixtures — so it must never be reachable in a real environment. A common pattern is to register it behind an environment flag that is set only in the verification job.
Implementation
Step 1: Write the verification harness. Point the Verifier at the running provider and the broker, and publish results.
// provider/verify.pact.test.ts
import { Verifier } from '@pact-foundation/pact';
import { describe, it } from 'vitest';
describe('order-service provider verification', () => {
it('honors all consumer contracts', async () => {
await new Verifier({
provider: 'order-service',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
providerVersion: process.env.GIT_COMMIT,
providerVersionBranch: process.env.GIT_BRANCH,
publishVerificationResult: true,
stateHandlersUrl: 'http://localhost:8080/_pact/provider-states',
consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
enablePending: true,
}).verifyProvider();
}, 60_000);
});
A few selector choices in that harness carry real weight. consumerVersionSelectors decides which contracts you verify: { mainBranch: true } pulls the latest contract from each consumer’s main branch, and { deployedOrReleased: true } pulls every version currently live in an environment, so you never ship a provider change that breaks something already in production. Together they cover the two populations that matter — what consumers are building next, and what is deployed right now — while ignoring stale feature-branch contracts that would only add noise. enablePending: true lets a brand-new consumer contract be reported without failing the build, which prevents a consumer’s experiment from blocking the provider’s pipeline. publishVerificationResult should be true only in CI — running it locally would post noisy results to the broker and pollute the deployment matrix with verifications tied to uncommitted code. Guard it with a check on process.env.CI so a developer running the file by hand never writes to the broker.
For larger contracts you can also map provider states in-process instead of over HTTP, which avoids a network hop and keeps fixture setup in the same module as your seed code:
// in-process state handlers (alternative to stateHandlersUrl)
import { db } from '../src/db';
const stateHandlers = {
'an order with id 42 exists': async () => {
await db.orders.upsert({ id: 42, total: 99.5, status: 'CONFIRMED' });
},
'no order with id 999 exists': async () => {
await db.orders.delete(999);
},
};
// pass `stateHandlers` to the Verifier instead of `stateHandlersUrl`
Whichever form you use, the state key must match the consumer’s given(...) text character for character — Pact does not normalize whitespace or case. Treat state descriptions as a shared vocabulary between the two teams and keep them boring and literal (“an order with id 42 exists”) rather than clever, because every character is a contract of its own. A common discipline is to keep the full list of state strings in one file on the provider so a missing handler is a visible gap rather than a runtime 404.
Step 2: Boot the provider before verifying. In CI, start the service (or use a globalSetup) so providerBaseUrl is live when the Verifier runs.
// provider/global-setup.ts
import { startServer, stopServer } from '../src/server';
export async function setup() {
await startServer(8080);
}
export async function teardown() {
await stopServer();
}
The subtle bug here is readiness versus liveness: startServer may resolve as soon as it binds the port, before migrations finish or the first request can be served. Poll the base URL until it returns 200 inside setup rather than trusting the resolve, so the Verifier never races a half-booted process.
Step 3: Run verification in CI as its own job.
# .github/workflows/provider-verify.yml
name: Pact Provider Verification
on:
push: { branches: [main] }
repository_dispatch:
types: [contract_requiring_verification_published]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- run: npx vitest run provider/verify.pact.test.ts
env:
PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_COMMIT: ${{ github.sha }}
GIT_BRANCH: ${{ github.ref_name }}
The repository_dispatch trigger is what lets a consumer’s newly published contract kick off verification without waiting for the provider’s next commit — the broker webhook fires the dispatch event, covered in the FAQ below.
Step 4: Gate deployment with can-i-deploy. Before promoting the provider, confirm it is compatible with every consumer version already in the target environment.
npx pact-broker can-i-deploy \
--pacticipant=order-service \
--version=$GIT_COMMIT \
--to-environment=production \
--broker-base-url=$PACT_BROKER_BASE_URL \
--broker-token=$PACT_BROKER_TOKEN
Step 5: Record the deployment so future can-i-deploy checks know what is live:
npx pact-broker record-deployment \
--pacticipant=order-service \
--version=$GIT_COMMIT \
--environment=production \
--broker-base-url=$PACT_BROKER_BASE_URL \
--broker-token=$PACT_BROKER_TOKEN
Verification
A successful run logs each interaction with a green check and a verification summary, then posts results to the broker:
Verifying a pact between web-storefront and order-service
a request for order 42
returns a response which
has status code 200 (OK)
has a matching body (OK)
1 interaction, 0 failures
In the broker matrix, the contract flips from unverified to verified for your provider version. The deployment gate is the decisive signal: can-i-deploy exits 0 and prints a compatibility matrix when every required verification is green, and exits non-zero when any is missing or failing. Treat that exit code as the release gate, not the test log. A green verification job tells you the provider satisfied the contracts it fetched this run; the exit code of can-i-deploy tells you the provider is safe against the specific versions live in the environment you are about to deploy to. Those are different questions, and only the second one belongs in the release gate.
The ordering of these signals matters and is easy to get wrong. Verification publishes a result (this provider version satisfies these contracts); can-i-deploy reads the accumulated results to answer a deployment question (is this version safe against everything live in the target environment); record-deployment then updates the broker’s notion of what is live so the next can-i-deploy reflects reality. Run them strictly in that order. If you record the deployment before it actually succeeds, the broker believes a version is live that never shipped, and a later gate will happily approve a change against a phantom. If you gate before verification publishes, the broker has no result for your version and the gate blocks on missing data. The three commands are not interchangeable steps; each produces a fact the next one consumes, so the sequence is load-bearing.
A common mistake is gating on the verification job’s exit code alone — that only proves the provider satisfied the contracts it happened to fetch, not that it is compatible with every consumer version currently deployed, which is exactly the gap can-i-deploy closes. Imagine a consumer version that is live in production but whose branch was deleted, so { mainBranch: true } no longer selects it. Verification passes because it never fetched that contract; can-i-deploy still blocks because deployedOrReleased remembers what is live. That divergence is the entire reason the gate is a separate command.
When a verification genuinely fails, the log names the interaction, the expected matching rule, and the actual response, so the diff is unambiguous:
Verifying a pact between web-storefront and order-service
a request for order 42
returns a response which
has a matching body (FAILED)
Failures:
1) Verifying a pact ... has a matching body
$.total -> Expected a decimal number but got "99.50" (String)
That failure says the provider started returning total as a string while a consumer still reads it as a number — a real incompatibility the gate correctly blocks. The value of the format is that it points at a JSON path and a type, not a stack trace, so the fix is obvious to whichever team owns the endpoint.
Troubleshooting
Provider state not applied, endpoint returns 404. The given(...) state has no matching handler, so the fixture row is never created and the handler answers as if the order does not exist. Verify stateHandlersUrl is reachable and that the state string matches the consumer’s given(...) text exactly — it is a literal string match, so a trailing space or a capitalized word is enough to silently skip seeding. Log the incoming state string in the handler during development so an unmatched value is visible rather than swallowed.
New consumer contract breaks the provider build immediately. Without enablePending: true, a freshly published contract that the provider has never verified fails the build the moment it appears, which turns another team’s work-in-progress into your outage. Enable pending so new contracts are reported but non-blocking until the provider has had a chance to satisfy them; the result is still recorded, so the expectation is visible and gets addressed rather than ignored.
Verification passes locally but fails in CI. Usually a version-selector or environment mismatch: locally you verify the latest contract, but CI’s consumerVersionSelectors resolve a different set that includes a deployed version you did not have on disk. Pin selectors to mainBranch plus deployedOrReleased so CI verifies exactly the contracts that matter for the environments you deploy to, mirroring your test pyramid strategy. If the two environments still diverge, print the resolved pact URLs at the start of the run so you can compare what each actually fetched.
The Verifier cannot reach providerBaseUrl. The service was not fully booted when verification started — startServer resolved before the port was actually listening, or a slow migration delayed readiness. Add a health-check poll in globalSetup that waits for the base URL to return 200 before the Verifier runs, rather than relying on a fixed sleep, which is either too short on a slow runner or wastefully long on a fast one.
Body matches but a header verification fails. Pact verifies any headers the contract recorded with matching rules, including Content-Type. If the provider now returns application/json; charset=utf-8 where the contract expects application/json, that is a real mismatch — either align the provider or have the consumer record a regex matcher for the header so charset variations are tolerated. Header mismatches are easy to miss because the body diff looks clean; read the full failure block, not just the first line.
A flaky verification run blocks the gate. Non-deterministic provider responses — a timestamp generated at request time, a randomized id — will intermittently fail a literal comparison. The fix is on the consumer side: those fields should have been recorded with iso8601DateTime or integer matchers so the contract asserts shape rather than exact value. If you cannot change the consumer immediately, scope a requestFilter in the Verifier to normalize the volatile field before comparison, but treat that as a temporary bridge, not the resolution.
Provider version is not in the matrix for can-i-deploy. You ran can-i-deploy before verification published its result, so the broker has no record for that version and the gate blocks on missing data rather than a real incompatibility. Sequence the jobs so verification completes and posts results first, then gate, then record-deployment only after the deploy actually succeeds — the ordering the pipeline diagram above makes explicit.
FAQ
How do webhooks trigger verification automatically?
Configure a broker webhook that fires on the contract_requiring_verification_published event and calls your CI provider’s dispatch API. When a consumer publishes a contract that the current provider version has not yet verified, the broker posts to the webhook, which starts the provider’s verification job through the repository_dispatch trigger shown in the workflow above. This decouples the two pipelines — the provider re-verifies on demand rather than waiting for its next commit, so a consumer waiting on a new contract gets a verdict in minutes instead of after the provider’s next unrelated push.
What does can-i-deploy actually check?
It queries the broker’s matrix for the pacticipant and version you name and asks whether that version is compatible with every other application version currently recorded in the target environment. If a required verification is missing or failed, it exits non-zero. It is a deploy-time safety gate, not a test — run it after verification has published results, immediately before promoting a build. The distinction matters because a passing verification job answers “did this version satisfy the contracts it fetched?” while can-i-deploy answers “is this version safe against everything live right now?”, and only the second question should hold a release.
Does provider verification need the real database?
It needs whatever the provider’s handlers need to return the contracted response, seeded through provider-state handlers. Most teams point the provider at a disposable test database or an in-memory store and use the given(...) states to insert just the fixtures each interaction requires, keeping verification fast and deterministic without external service simulation. The key property is isolation: each state handler should establish its own precondition and not depend on rows left behind by a previous interaction, or the run becomes order-dependent and flaky.
Can I run verification for one consumer only?
Yes — narrow consumerVersionSelectors to a specific consumer, or use the --consumer filter on the CLI verifier. This is useful when debugging a single failing boundary, but in CI you generally verify all selected consumers so no contract is silently skipped. Treat single-consumer runs as a local investigation tool and keep the full selector set as the authoritative check that gates deployment.
What is the point of enablePending if it lets new contracts pass?
Pending lets a consumer publish a contract the provider has never verified without immediately turning the provider’s build red. The contract is still verified and the result is recorded — it just does not fail the job until the provider has had a real chance to satisfy it. This prevents a consumer’s in-progress change from blocking unrelated provider work, while still surfacing the new expectation so it gets addressed rather than ignored. Once the provider verifies that contract successfully, it graduates out of pending and any future regression against it fails the build normally.
How is provider verification different from an integration test?
An integration test exercises the provider against fixtures you chose, proving the provider works the way you expect. Provider verification exercises it against contracts consumers actually published, proving it works the way they expect — and posts that result to a broker so a deployment gate can reason about compatibility across versions. They are complementary: integration tests cover provider-internal correctness, verification covers the cross-team boundary that no single team’s tests otherwise touch. You want both, because a provider can be internally correct and still break a consumer that depended on a field the provider considered incidental.
Related
- Back to Contract Testing with Pact JS
- Pact JS consumer-driven contract flow — the consumer side that produces these contracts.
- Cypress vs Playwright for contract testing — how verification fits alongside browser E2E.
- External Service Simulation — covering dependencies you cannot verify against directly.