Skip to content
Dev Tools Intermediate Tutorial

Catch Breaking API Changes Before They Ship with Pact

Wire consumer-driven Pact tests into CI so a backend deploy fails before it breaks your frontend.

Mariana Souza
Mariana Souza
Senior Editor · Jul 21, 2026 · 6 min read
Catch Breaking API Changes Before They Ship with Pact

What you'll build

A consumer-driven contract between two Node.js services — order-web (frontend) and order-api (backend) — enforced by Pact: the consumer publishes its expectations as a pact file, the provider verifies against it on every commit, and a can-i-deploy gate in CI blocks any backend deploy that would break the frontend. Unlike schema diffing, this only locks down what consumers actually use — the provider stays free to change everything else.

Prerequisites

  • Node.js 22+ — @pact-foundation/pact v17 requires node >= 22.
  • Docker to run the Pact Broker locally (verified with Docker 29.x).
  • Versions verified for this tutorial: @pact-foundation/pact 17.0.1, @pact-foundation/pact-cli 18.1.1, Jest 30.4, Express 5.2, the pactfoundation/pact-broker Docker image. macOS and Linux are identical; on Windows use WSL2.

1. Scaffold the consumer

Two directories stand in for two repos:

mkdir -p pact-demo/order-web pact-demo/order-api
cd pact-demo/order-web
npm init -y
npm i -D @pact-foundation/pact@17 jest@30 @pact-foundation/pact-cli

The consumer's API client, src/api.js — plain fetch, nothing Pact-specific:

async function getOrder(baseUrl, id) {
  const res = await fetch(`${baseUrl}/orders/${id}`, {
    headers: { Accept: 'application/json' },
  });
  if (!res.ok) throw new Error(`Unexpected status ${res.status}`);
  return res.json();
}

module.exports = { getOrder };

2. Write the consumer contract test

The test runs your real client against a Pact mock server and records the interaction as a contract. Save as test/order-api.pact.test.js:

const path = require('path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { getOrder } = require('../src/api');

const { like, string, decimal } = MatchersV3;

const provider = new PactV3({
  consumer: 'order-web',
  provider: 'order-api',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('GET /orders/:id', () => {
  it('returns the order when it exists', () => {
    provider
      .given('an order with id 42 exists')
      .uponReceiving('a request for order 42')
      .withRequest({
        method: 'GET',
        path: '/orders/42',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: like({
          id: 42,
          status: string('shipped'),
          total: decimal(129.99),
        }),
      });

    return provider.executeTest(async (mockServer) => {
      const order = await getOrder(mockServer.url, 42);
      expect(order).toEqual({ id: 42, status: 'shipped', total: 129.99 });
    });
  });
});

Use matchers, not literals: string('shipped') means "any string here", so the provider isn't forced to return that exact value later. Run npx jest — on pass, Pact writes pacts/order-web-order-api.json.

3. Stand up a Pact Broker and publish the contract

The Pact Broker is the exchange point between the two pipelines. Run it locally with Docker (SQLite is fine for a demo; use Postgres in production — SQLite doesn't handle concurrent requests):

docker run -d --name pact-broker -p 9292:9292 \
  -e PACT_BROKER_DATABASE_ADAPTER=sqlite \
  -e PACT_BROKER_DATABASE_NAME=/tmp/pact_broker.sqlite3 \
  pactfoundation/pact-broker

Publish the pact from order-web/, versioned by git SHA and branch so the broker can track which app version expects what:

npx pact-broker publish ./pacts/*.json \
  --broker-base-url http://localhost:9292 \
  --consumer-app-version dev1 \
  --branch main

Locally any version string works; in CI, pass the git SHA (--consumer-app-version $GITHUB_SHA).

Open http://localhost:9292 — the order-web → order-api contract is listed, unverified.

4. Verify the provider against the contract

Set up the backend:

cd ../order-api
npm init -y
npm i express@5
npm i -D @pact-foundation/pact@17 jest@30 @pact-foundation/pact-cli

src/app.js:

const express = require('express');

function createApp(orders) {
  const app = express();
  app.get('/orders/:id', (req, res) => {
    const order = orders.get(Number(req.params.id));
    if (!order) return res.status(404).json({ error: 'not found' });
    res.json(order);
  });
  return app;
}

module.exports = { createApp };

The verification test, test/pact.verify.test.js, starts the real app and replays every interaction from the broker against it. The stateHandlers key must match the consumer's given() string exactly — that's how the provider seeds test data without sharing a database:

const { Verifier } = require('@pact-foundation/pact');
const { createApp } = require('../src/app');

const orders = new Map();
let server;

beforeAll((done) => {
  server = createApp(orders).listen(8081, done);
});

afterAll((done) => server.close(done));

it('honours the pacts published by consumers', () => {
  return new Verifier({
    provider: 'order-api',
    providerBaseUrl: 'http://127.0.0.1:8081',
    pactBrokerUrl: process.env.PACT_BROKER_BASE_URL || 'http://localhost:9292',
    consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
    publishVerificationResult: process.env.CI === 'true',
    providerVersion: process.env.GIT_COMMIT || 'dev',
    providerVersionBranch: process.env.GIT_BRANCH || 'main',
    stateHandlers: {
      'an order with id 42 exists': async () => {
        orders.set(42, { id: 42, status: 'shipped', total: 129.99 });
      },
    },
  }).verifyProvider();
}, 60000);

Run npx jest. The selectors pull the pacts that matter: each consumer's main branch plus whatever's actually deployed. Note the test never touches the consumer's code — the pact file is the only thing the two pipelines share, which is what lets them live in separate repos and deploy independently.

5. Gate deploys in CI with can-i-deploy

In the provider's GitHub Actions pipeline, verification publishes its result to the broker, then can-i-deploy refuses to proceed unless this exact SHA has passed against every consumer version in production:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
      - run: npm ci
      - run: npx jest
        env:
          CI: "true"
          PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }}
          GIT_COMMIT: ${{ github.sha }}
          GIT_BRANCH: ${{ github.ref_name }}
      - name: Can I deploy?
        if: github.ref == 'refs/heads/main'
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant order-api --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url ${{ vars.PACT_BROKER_BASE_URL }}
      # ... your deploy step, then:
      - name: Record deployment
        if: github.ref == 'refs/heads/main'
        run: |
          npx pact-broker record-deployment \
            --pacticipant order-api --version ${{ github.sha }} \
            --environment production \
            --broker-base-url ${{ vars.PACT_BROKER_BASE_URL }}

can-i-deploy exits non-zero if verification is missing or failed, which fails the job before your deploy step runs — that's the gate. The broker pre-creates test and production environments; record-deployment after each release tells it what's live. The consumer pipeline mirrors this: publish the pact, then can-i-deploy --pacticipant order-web before shipping the frontend. For CI you'll want a broker both pipelines can reach — self-host the same image, or use a hosted one.

Verify it works

Consumer side (order-web), npx jest:

Tests:       1 passed, 1 total

Provider side (order-api), npx jest prints the replayed interaction:

Verifying a pact between order-web and order-api

  a request for order 42
     Given an order with id 42 exists
    returns a response which
      has status code 200 (OK)
      includes headers
        "Content-Type" with value "application/json" (OK)
      has a matching body (OK)

Now prove the gate catches a breaking change. In src/app.js, make the route rename the field — res.json({ id: order.id, state: order.status, total: order.total }) — and rerun the provider's npx jest:

1) Verifying a pact between order-web and order-api Given an order with id 42 exists - a request for order 42
    1.1) has a matching body
           $ -> Actual map is missing the following keys: status

There were 1 pact failures

In CI that failed verification lands in the broker, can-i-deploy exits non-zero, and the deploy never happens. The frontend team finds out from your pipeline — not from production.

Troubleshooting

  • npm warn EBADENGINE Unsupported engine / required: { node: '>=22' } on install — you're on Node 20 or older. pact-js v17 ships a native core built for Node 22+; upgrade Node rather than forcing the install.
  • no state handler found for state: "an order with id 42 exists" during verification, followed by a body mismatch or 404 — the string in stateHandlers doesn't exactly match the consumer's given(). Copy it verbatim; it's an identifier, not prose.
  • ❌ Failed to access pact broker path '' - error sending request for url (http://localhost:9292/) when publishing — the broker isn't up yet or the URL is wrong. Check docker ps and hit http://localhost:9292/diagnostic/status/heartbeat until it responds — first startup can take a few seconds while the database initialises.
  • $ -> Actual map is missing the following keys: ... — this isn't a tooling problem; the provider genuinely dropped a field a consumer relies on. Either restore it, or change the consumer's test first and republish the pact — that's the consumer-driven workflow doing its job.

Next steps

Add a broker webhook so publishing a changed pact triggers provider verification immediately instead of waiting for the provider's next commit. Read up on pending pacts and WIP pacts so new consumer expectations don't break the provider's build before it has a chance to implement them. When you outgrow a self-hosted broker, PactFlow is the managed option. And if your services also talk over queues, Pact's message pacts apply the same contract discipline to async events.

Sources & further reading

  1. Pact JS (v17) — github.com
  2. Verifying Pacts - JavaScript — docs.pact.io
  3. Pact Broker Docker Image — docs.pact.io
  4. Consumer Version Selectors — docs.pact.io
  5. Recording Deployments and Releases — docs.pact.io
Mariana Souza
Written by
Mariana Souza · Senior Editor

Mariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading