Skip to main content

Consumer-Driven Contracts with Pact

Consumer-driven contracts (CDC) invert the usual integration-testing model: instead of a provider publishing a spec and hoping consumers conform, each consumer records exactly the interactions it depends on, and the provider proves it satisfies them before it ships. Pact is the de facto CDC framework, and this guide walks the full loop end to end — consumer test, pact file, broker, provider verification, provider states, can-i-deploy, and CI gating — using Pact JS 12. It extends the paradigms surveyed in API Contract Fundamentals & Tool Selection, and sits alongside Contract Testing for Microservices as the hands-on implementation reference.

The core promise is independent deployability: a provider team can refactor freely as long as the recorded contracts still pass, and a consumer team gets a fast, hermetic test suite that never touches a real backend. The same recorded interactions double as mock server fixtures during local development, so the contract you test against is the contract you code against.

Pact consumer-driven contract flow A consumer test generates a pact file, which is published to the broker; the provider pulls it, verifies it against provider states, publishes results, and can-i-deploy gates the deployment. Consumer test PactV3 + matchers runs vs mock server pact file (JSON) recorded interactions Pact Broker version matrix + can-i-deploy Provider verify Verifier replays + provider states Deploy gate CI blocks if unsafe generates publish pull pacts results can-i-deploy

When to Use This Approach

Consumer-driven contracts pay off when integrations outnumber the teams that own them and deployments are independent. Reach for Pact when:

  • Multiple consumers depend on one provider, and you need to know which consumers break before the provider ships a change.
  • Teams deploy on independent cadences and cannot run a full end-to-end suite on every merge.
  • You want fast, hermetic consumer tests that run with no live backend, using the recorded contract as the mock.
  • HTTP or message-based service-to-service traffic is the integration surface — Pact covers both synchronous REST and asynchronous events.
  • You need a deployment gate, not just a passing test, so a green build provably means “safe to release against production.”

It is the wrong tool when the relationship is a one-way public API with unknown consumers (publish an OpenAPI spec and validate against it instead), or when you only need static schema documentation. The trade-offs are weighed in detail in Pact vs OpenAPI for frontend-backend integration.

Prerequisites

You need a consumer project and a provider project (Node.js examples below), plus a broker to exchange contracts between them.

  • Pact JS 12 (@pact-foundation/pact v12.x) installed in both consumer and provider projects.
  • The Pact Broker CLI (@pact-foundation/pact-cli, which bundles pact-broker, pactflow, and can-i-deploy).
  • A broker instance — either self-hosted Pact Broker (the pactfoundation/pact-broker Docker image) or hosted PactFlow. For a small team the choice is non-obvious; see Pact Broker vs PactFlow for small teams.
  • A test runner — Jest 29+ or Vitest. Pact tests must run serially.
# In both consumer and provider projects
npm install --save-dev @pact-foundation/pact@^12

# CLI tools, used in CI (publish, verify, can-i-deploy)
npm install --save-dev @pact-foundation/pact-cli

Configure the runner so pact specs never run in parallel — each test spins up a mock server on an ephemeral port, and parallel workers collide:

{
  "scripts": {
    "test:pact:consumer": "jest --testMatch '**/*.consumer.pact.ts' --runInBand",
    "test:pact:provider": "jest --testMatch '**/*.provider.pact.ts' --runInBand --detectOpenHandles"
  }
}

Step 1 — Write the Consumer Test and Generate a Pact File

The contract is born from a real consumer test. You declare the interactions your client code needs, run your actual client against Pact’s mock server, and a passing test writes a JSON pact to disk. Use matchers (integer, string, uuid, eachLike) rather than literal values — the contract should pin the shape of the response, not the exact data, otherwise it shatters whenever the provider returns a different but valid value.

// users.consumer.pact.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { fetchUser } from '../src/userClient'; // your real client code

const { integer, string, eachLike } = MatchersV3;

const provider = new PactV3({
  consumer: 'web-frontend',
  provider: 'user-api',
  dir: './pacts',           // pact JSON is written here
});

describe('user-api contract', () => {
  it('returns a user profile with roles', () => {
    provider
      .given('user 123 exists')                       // provider-state precondition
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/api/v1/users/123',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: integer(123),                            // any integer satisfies the contract
          name: string('Alice'),
          roles: eachLike(string('admin')),           // a non-empty array of strings
        },
      });

    // Run YOUR client against the mock; assertions confirm it parses the contract
    return provider.executeTest(async (mockServer) => {
      const user = await fetchUser(mockServer.url, 123);
      expect(user.id).toBe(123);
      expect(user.roles.length).toBeGreaterThan(0);
    });
  });
});

Running npm run test:pact:consumer produces ./pacts/web-frontend-user-api.json. Note the given('user 123 exists') string — it is the seed for provider states in Step 3, and it must match byte-for-byte on the provider side.

Step 2 — Publish the Pact to the Broker

The pact file is useless on a developer’s laptop. Publish it to the broker tagged with the consumer’s version and branch, so the broker can later answer “which version of which consumer needs what.” Use the commit SHA as the version and the branch name as metadata — branch tagging is what makes the mainBranch and deployedOrReleased selectors resolve correctly during verification.

# Run in CI after consumer tests pass
npx pact-broker publish ./pacts \
  --consumer-app-version "$GITHUB_SHA" \      # unique, immutable version
  --branch "$GITHUB_REF_NAME" \               # enables branch-based selectors
  --broker-base-url "$PACT_BROKER_URL" \
  --broker-token "$PACT_BROKER_TOKEN"

In a GitHub Actions workflow this is a step in the consumer pipeline:

# .github/workflows/consumer.yml
jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run test:pact:consumer
      - name: Publish pacts
        run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version "${{ github.sha }}" \
            --branch "${{ github.ref_name }}" \
            --broker-base-url "${{ secrets.PACT_BROKER_URL }}" \
            --broker-token "${{ secrets.PACT_BROKER_TOKEN }}"

The broker now holds a versioned contract and, crucially, can trigger the provider via a webhook on contract_content_changed so verification happens automatically when a consumer’s expectations actually change.

Step 3 — Verify the Provider with Provider States

Verification replays every recorded interaction against the real running provider. The provider pulls pacts from the broker, and for each one it first puts itself into the precondition declared by given() — this is the provider state. State handlers seed the database, insert a fixture, or stub a downstream so the request returns the response the consumer expects. Without them, given('user 123 exists') interactions fail with 404s because no such user exists in a clean test environment.

// users.provider.pact.ts
import { Verifier } from '@pact-foundation/pact';
import { startServer, db } from '../src/testServer';

describe('user-api provider verification', () => {
  let server: { url: string; close: () => Promise<void> };

  beforeAll(async () => { server = await startServer(); });
  afterAll(async () => { await server.close(); });

  it('honours all consumer contracts', () => {
    return new Verifier({
      provider: 'user-api',
      providerBaseUrl: server.url,
      pactBrokerUrl: process.env.PACT_BROKER_URL!,
      pactBrokerToken: process.env.PACT_BROKER_TOKEN!,
      providerVersion: process.env.GITHUB_SHA,            // ties results to this build
      providerVersionBranch: process.env.GITHUB_REF_NAME,
      publishVerificationResult: true,                    // write results back to broker
      consumerVersionSelectors: [
        { mainBranch: true },                             // verify main-branch pacts
        { deployedOrReleased: true },                     // and what is live today
      ],
      // One handler per distinct given() string used by any consumer
      stateHandlers: {
        'user 123 exists': async () => {
          await db.users.upsert({ id: 123, name: 'Alice', roles: ['admin'] });
        },
      },
    }).verifyProvider();
  });
});

Set publishVerificationResult: true only in CI — it records the pass/fail against the provider version in the broker, which is exactly the data can-i-deploy reads in Step 4. The consumerVersionSelectors control which contracts to verify; verifying both the main branch and currently deployed versions guards against breaking either the next release or production. A deeper walkthrough of the CI mechanics lives in verifying provider contracts in CI with Pact.

Step 4 — Gate Deployment with can-i-deploy

A passing verification is not the same as “safe to deploy.” can-i-deploy asks the broker a precise question: has the version I am about to deploy been verified against every version it must be compatible with in the target environment? If a required verification is missing or failing, it exits non-zero and the pipeline halts.

# Gate the provider release on the production matrix
npx pact-broker can-i-deploy \
  --pacticipant user-api \
  --version "$GITHUB_SHA" \                    # the build you want to deploy
  --to-environment production \                # check compatibility with prod
  --broker-base-url "$PACT_BROKER_URL" \
  --broker-token "$PACT_BROKER_TOKEN" \
  --retry-while-unknown 12 \                   # wait for in-flight verifications
  --retry-interval 10

Wire it as a hard gate before the deploy step, and record the deployment so the matrix stays accurate for the next service that asks:

# .github/workflows/provider.yml (deploy job)
      - name: Can I deploy?
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant user-api --version "${{ github.sha }}" \
            --to-environment production \
            --broker-base-url "${{ secrets.PACT_BROKER_URL }}" \
            --broker-token "${{ secrets.PACT_BROKER_TOKEN }}" \
            --retry-while-unknown 12 --retry-interval 10
      - name: Deploy
        run: ./deploy.sh
      - name: Record deployment
        run: |
          npx pact-broker record-deployment \
            --pacticipant user-api --version "${{ github.sha }}" \
            --environment production \
            --broker-base-url "${{ secrets.PACT_BROKER_URL }}" \
            --broker-token "${{ secrets.PACT_BROKER_TOKEN }}"

record-deployment is what keeps --to-environment production meaningful — it tells the broker which version is actually live, so the next consumer or provider that runs can-i-deploy is checked against reality, not against whatever last merged to main. This is the same broker-centric gating pattern applied across services in Contract Testing for Microservices.

Spec / Schema Reference

Key options across the consumer DSL, the Verifier, and the CLI:

Option Type Default Effect
consumer / provider (PactV3) string Names the two participants; defines the pact filename and broker identity.
dir (PactV3) string ./pacts Directory the generated pact JSON is written to.
given(state) string none Records the provider-state precondition for an interaction; must match a provider stateHandlers key.
eachLike(template, opts) matcher min 1 Matches a non-empty array of items shaped like the template.
providerBaseUrl (Verifier) string URL of the running provider the recorded requests replay against.
consumerVersionSelectors object[] latest Chooses which consumer pacts to verify (e.g. mainBranch, deployedOrReleased).
publishVerificationResult boolean false Writes pass/fail back to the broker; enable in CI only.
stateHandlers record {} Maps each given() string to an async setup function run before the interaction.
--consumer-app-version (publish) string Immutable version (use the commit SHA) the pact is tagged with.
--branch (publish) string none Branch metadata that powers mainBranch / deployedOrReleased selectors.
--to-environment (can-i-deploy) string Target environment whose deployed versions are checked for compatibility.
--retry-while-unknown (can-i-deploy) int 0 Seconds to poll while verifications are still in flight before failing.

Verification

A clean consumer run writes the pact and reports the interaction as verified locally:

$ npm run test:pact:consumer

  user-api contract
    ✓ returns a user profile with roles (42 ms)

  Pact written to: ./pacts/web-frontend-user-api.json

A successful provider run reports each interaction and pushes results to the broker:

$ npm run test:pact:provider

Verifying a pact between web-frontend and user-api
  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)

1 interaction, 0 failures
INFO: Published verification result for user-api version 

And the deployment gate prints an explicit verdict before the deploy step is allowed to run:

$ npx pact-broker can-i-deploy --pacticipant user-api --version  --to-environment production

Computer says yes \o/

All required verification results are published and successful.

A non-zero exit (“Computer says no”) stops the pipeline before anything reaches production.

Troubleshooting

Provider verification fails with Interaction not found or HTTP 404. The provider state was never satisfied — there is no stateHandlers entry for the given() string, or the string differs from the consumer’s (it is case- and whitespace-sensitive). Open the pact JSON, copy the exact providerStates[].name, and register a handler that seeds the matching data before the interaction runs.

can-i-deploy returns false even though all tests passed. The broker cannot find a verification for the required pair of versions. Confirm the provider ran with publishVerificationResult: true, that both sides published with --branch, and that the version you are checking is the one that was actually verified. Inspect the broker’s matrix view to see which cell is empty or red.

can-i-deploy reports “unknown” and stalls. Verification for that version has not finished (or never started). Add --retry-while-unknown with a sensible window so the gate waits for an in-flight provider build, and make sure a webhook or pipeline actually triggers provider verification when a new pact is published.

The contract breaks on every provider data change. The consumer test uses literal values instead of matchers, so any valid-but-different response fails. Replace hardcoded fields with integer(), string(), uuid(), datetime(), or eachLike(), then regenerate and republish the pact.

Mock server port collisions or hanging Jest workers. Pact specs ran in parallel, or a mock server was not torn down. Run with --runInBand, let executeTest manage the mock lifecycle, and add --detectOpenHandles on the provider side to surface a server you forgot to close in afterAll.

Frequently Asked Questions

What is the difference between a consumer-driven contract and an OpenAPI spec?

An OpenAPI spec describes everything a provider can do; a consumer-driven contract records only the subset of interactions a specific consumer actually relies on. Pact generates the contract from real consumer test runs, so it captures exact requests and the response fields the consumer reads, not the full provider surface.

Do I need a Pact Broker, or can I share pact files directly?

You can verify pact files from a local path for a single repo, but for any multi-service setup you need a broker. The broker stores version history, tracks which versions are deployed to each environment, and powers can-i-deploy, which is what makes safe independent deployment possible.

What does can-i-deploy actually check?

can-i-deploy queries the broker’s verification matrix and answers whether the application version you are about to deploy has been verified as compatible with the versions already deployed (or about to be deployed) in the target environment. If any required verification is missing or failing, it exits non-zero and your pipeline stops.

Why do my provider verifications need provider states?

Each consumer interaction is recorded with a given() precondition such as ‘user 123 exists’. During verification the provider must put itself into that state before the request replays. State handlers seed or mock the data so the request returns the expected response without depending on whatever happens to be in the database.

Can Pact test asynchronous and message-based interactions?

Yes. Pact JS 12 supports message pacts via MessageConsumerPact and a message verifier, which validate the payload and metadata of events rather than HTTP request/response pairs. This is how you extend contract testing to Kafka, RabbitMQ, or other event-driven flows.

Should the consumer or the provider version be the source of truth in CI?

Both are tracked independently. The consumer publishes a pact tagged with its version and branch; the provider publishes verification results tagged with its own version. The broker correlates them, so neither side is canonical — compatibility is a property of a specific pair of versions.