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.
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: a provider team refactors an endpoint, all 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 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. 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.
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.
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();
});
}
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. 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.
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.
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();
}
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 }}
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.
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. 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. 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.
Troubleshooting
Provider state not applied, endpoint returns 404. The given(...) state has no matching handler, so the fixture row is never created. Verify stateHandlersUrl is reachable and that the state string matches the consumer’s given(...) text exactly — it is a literal string match.
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. Enable pending so new contracts are reported but non-blocking until the provider has had a chance to satisfy them.
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. 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.
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.
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.
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. If you cannot change the consumer immediately, scope a requestFilter in the Verifier to normalize the volatile field before comparison.
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. Sequence the jobs so verification completes and posts results first, then gate, then record-deployment only after the deploy actually succeeds.
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. This decouples the two pipelines — the provider re-verifies on demand rather than waiting for its next commit.
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.
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.
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.
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.
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.
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.