Skip to main content

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 undefined or an unexpected null cast, 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-deploy returns “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:

  1. No verification result is ever published to the broker.
  2. can-i-deploy finds an empty cell in the matrix and either exits with “unknown” or, if the gate is absent, never runs at all.
  3. The provider deploys without any check against what consumers depend on.
  4. 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.

Provider verification and can-i-deploy sequence A sequence diagram showing the provider CI pipeline: build triggers verification, the Verifier pulls pacts from the broker, runs provider states and replays interactions, publishes results, then can-i-deploy checks the matrix before the deploy step proceeds. CI build (GitHub Actions) Verifier (Pact JS 12) Pact Broker version matrix Provider live on test port run verifier pull pacts (selectors) pact JSON POST /_pact/provider-states (setup) replay recorded requests actual responses publish results (pass/fail) can-i-deploy? (matrix query) yes / no (exit 0 / 1) deploy / block

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.