AverageDevs
Architecture

API Contract Testing in TypeScript: Catch Breaking Changes Before Production

A practical, engineering-first guide to consumer driven contract testing for TypeScript APIs, covering Pact, CI workflows, versioning, webhooks, and production rollout patterns.

API Contract Testing in TypeScript: Catch Breaking Changes Before Production

Imagine it is Friday afternoon. The backend team ships a "tiny cleanup" that renames userId to id because, honestly, it does look cleaner. The tests are green. The deploy is green. Everyone starts mentally leaving the office.

Then the frontend quietly explodes.

Not in a fun fireworks way. More like a vending machine eating your money and blinking "processing" forever. The UI expected userId. The API now returns id. The backend did not technically crash, the frontend did not technically have bad code, and yet users are now staring at a profile page that has entered witness protection.

Welcome to the deeply glamorous world of API contracts. This is where separately correct systems meet each other in production and discover they have been lying about their relationship status.

API contract testing exists to stop this nonsense before it reaches users. Instead of only testing one service alone in its little unit-test apartment, you test the agreement between a consumer and a provider: routes, payloads, headers, status codes, error envelopes, and the fields real clients depend on. If you already care about Best Practices for API Versioning and Backward Compatibility, contract tests are the executable seatbelt for that whole philosophy.

This guide focuses on TypeScript teams building REST APIs, GraphQL APIs, and webhook consumers. We will use Pact-style consumer driven contracts, but the engineering approach applies even if you use OpenAPI diffs, GraphQL operation checks, or a custom verification tool. If you are still designing your API shape, pair this with A Beginner's Guide to REST vs GraphQL APIs - When to Use Which. If your contracts travel through events instead of direct HTTP calls, keep Reliable Webhook Delivery: Idempotent and Secure close by, because webhooks love turning tiny assumptions into very expensive archaeology.

When Green Tests Still Betray You

Most teams already have tests. That is good. But the usual stack leaves a weird gap:

  • The frontend tests mock the API.
  • The backend tests mock the client.
  • End to end tests cover only a few happy paths because browsers are slow and CI minutes are not free.
  • Production gets to discover the remaining 400 edge cases because apparently production needed a hobby.

Contract testing fills the gap between isolated confidence and full end to end testing. It asks a very specific question:

If this consumer sends the request it actually sends in production, can the provider still respond in the shape the consumer actually relies on?

That means a contract can cover:

  • HTTP method, path, query params, and important headers.
  • Request body shape and value constraints.
  • Response status, body fields, content type, and error envelope.
  • Pagination fields such as nextCursor, hasMore, or totalCount.
  • Webhook event fields such as event.id, event.type, and data.invoice.total.

The magic is not that contract tests are huge. The magic is that they are specific. They turn "please do not break the mobile app" into a CI check that either passes or fails without a Slack thread that starts with "quick question."

Contracts With Teeth

Documentation is useful, but documentation also has the survival instincts of a houseplant in a basement. It drifts. It gets stale. Someone changes the API and says, "I will update the docs after lunch." Lunch becomes next quarter.

A contract test is different. It is executable. If the provider breaks the shape that a real consumer depends on, CI starts yelling before customers do.

This connects directly to Best Practices for API Versioning and Backward Compatibility. Backward compatibility is not a beautiful intention written in an architecture document. It is the discipline of proving that old consumers still work while new code ships. Contract tests are how you keep that proof fresh.

Let Consumers Write the Terms

In consumer driven contract testing, the consumer writes a test describing how it talks to the provider. That test produces a contract file. The provider then verifies that contract against the real API implementation.

The flow looks like this:

Consumer code
  |
  v
Consumer contract test
  |
  v
Contract artifact
  |
  v
Provider verification in CI
  |
  v
Deploy only if known consumers still work

This flips the normal provider-first habit. The API team does not sit in a conference room guessing what clients might need. The actual consumer says, "Here is the request I make, here is the response I require, please do not turn my UI into soup."

For REST APIs, that usually means route and JSON shape verification. For GraphQL, it often means validating real consumer operations against the schema. The tradeoffs between those API styles are covered in A Beginner's Guide to REST vs GraphQL APIs - When to Use Which, but both styles benefit from executable expectations.

Code Time: Catching the Friday Rename

Let us build the small test that would have caught our userId disaster. The consumer is a billing UI. The provider is a user API. The UI needs a stable user profile response.

import { PactV3, MatchersV3 } from "@pact-foundation/pact";

const { like, uuid, iso8601DateTime } = MatchersV3;

const provider = new PactV3({
  consumer: "billing-web",
  provider: "user-api",
});

export const getUser = async ({ baseUrl, userId }: { baseUrl: string; userId: string }) => {
  const response = await fetch(`${baseUrl}/users/${userId}`);

  if (!response.ok) {
    throw new Error("Failed to load user");
  }

  return response.json() as Promise<{
    userId: string;
    email: string;
    status: "active" | "disabled";
    createdAt: string;
  }>;
};

it("loads an active user profile", async () => {
  await provider
    .given("a user exists")
    .uponReceiving("a request for a user profile")
    .withRequest({
      method: "GET",
      path: "/users/123",
    })
    .willRespondWith({
      status: 200,
      headers: { "Content-Type": "application/json" },
      body: {
        userId: uuid("123"),
        email: like("[email protected]"),
        status: like("active"),
        createdAt: iso8601DateTime("2026-04-26T10:00:00.000Z"),
      },
    })
    .executeTest(async (mockServer) => {
      const user = await getUser({ baseUrl: mockServer.url, userId: "123" });
      expect(user.status).toBe("active");
    });
});

This test does two jobs. First, it proves the consumer can parse the response shape it expects. Second, it writes down a contract that the provider must continue satisfying.

Notice the matchers. We are not saying the email must literally be [email protected] forever. That would be brittle. We are saying the field exists and behaves like a string. Good contracts are strict about meaning and flexible about noise. Bad contracts either test every comma like a paranoid accountant or allow anything like a nightclub with no bouncer.

Put the API on the Stand

Now the provider has to prove it still satisfies the consumer contract. This should run in provider CI before the API deploys.

import { Verifier } from "@pact-foundation/pact";

const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:3000";

export const verifyContracts = async () => {
  return new Verifier({
    provider: "user-api",
    providerBaseUrl: API_BASE_URL,
    pactUrls: ["./pacts/billing-web-user-api.json"],
    stateHandlers: {
      "a user exists": async () => {
        await seedUser({
          userId: "123",
          email: "[email protected]",
          status: "active",
        });
      },
    },
  }).verifyProvider();
};

verifyContracts().catch((error) => {
  console.error(error);
  process.exit(1);
});

The stateHandlers are important. They prepare the provider for each interaction. If the contract says "a user exists," the provider test must create that user before verification. Otherwise your tests become a haunted house of random failures.

Keep state setup small and deterministic. If verifying one contract requires half your production database, two Redis clusters, and a goat sacrifice under a full moon, your service boundary is probably too tangled. The boundary discipline from Practical Guide to Implementing Clean Architecture in Full-Stack Projects helps here. Clean adapters make contracts easier to verify without dragging every internal detail into the room.

What Actually Belongs in the Contract

Here is the tricky part: contract tests should not freeze your entire API response like a bug trapped in amber. Providers need room to add fields and improve internals. Consumers need stability for the things they actually use.

Put these in contracts:

  • Required fields the consumer reads.
  • Status codes the consumer branches on.
  • Error response shapes shown to users.
  • Pagination fields needed for list screens.
  • Auth headers and content type assumptions.
  • Webhook event IDs, event types, and business payload fields.

Usually leave these out:

  • Optional fields the consumer ignores.
  • Field ordering in JSON.
  • Debug metadata.
  • Exact generated timestamps unless the timestamp itself matters.
  • Provider-only fields that exist for internal observability.

This is the same spirit as Best Practices for API Versioning and Backward Compatibility: additive changes should be safe, while breaking changes deserve migration plans. A contract should block accidental breakage, not punish harmless evolution.

Webhook Contracts: Events Can Lie Too

Webhooks are just APIs wearing a trench coat. The provider sends an event, the consumer receives it later, and everyone pretends the network is civilized.

If you publish events like invoice.paid, user.deleted, or subscription.cancelled, consumers depend on those payloads just as much as they depend on REST responses. A billing analytics worker may require event.id, event.type, createdAt, data.invoice.total, and data.customer.id. If one field disappears, the consumer may not fail loudly. It may just stop reporting revenue correctly, which is a delightful way to ruin a Monday.

The delivery concerns are covered deeply in Reliable Webhook Delivery: Idempotent and Secure: signatures, retries, replay protection, delivery state, and idempotency. Contract testing adds another layer: the payload itself must still mean what consumers expect.

For webhook contracts, verify:

  • Event type names are stable.
  • Required business fields exist.
  • IDs are present for idempotency and tracing.
  • Version fields are included when payloads evolve.
  • Old consumers still receive compatible payloads during migrations.

Delivery reliability is wonderful, but reliably delivering the wrong shape is still wrong. It is just wrong with better uptime.

Versioning Without Airport Security

Contracts should make deployments safer, not make engineers dread merging code. A sane workflow looks like this:

  1. Consumers publish contracts after their tests pass.
  2. Providers verify active consumer contracts before deploy.
  3. Breaking changes require a new field, new endpoint, new operation, or versioned behavior.
  4. Old contracts expire only after you know old consumers have moved.

If you use a Pact broker, the "can I deploy" workflow can answer whether a specific provider version has been verified against the consumer versions that matter. If you do not use a broker yet, start with contract artifacts in CI. Fancy tooling is helpful, but discipline beats dashboards every time.

This also connects back to Practical Guide to Implementing Clean Architecture in Full-Stack Projects. A clean boundary is not just nice folder organization. It is a promise that outside consumers can keep working while your internal code evolves.

A CI Workflow Humans Will Tolerate

Start with the smallest useful loop:

Consumer PR:
  run unit tests
  run consumer contract tests
  publish contract artifact

Provider PR:
  start API locally
  fetch active contracts
  verify contracts
  block merge on failures

Release:
  verify deployed provider candidate
  deploy only when the consumer-provider matrix is green

Do not begin by contract-testing every endpoint in the company. That is how good engineering ideas become archaeology projects. Start with expensive breakages:

  • Authentication and authorization flows.
  • Billing and subscription APIs.
  • Onboarding APIs.
  • High-traffic mobile endpoints.
  • Webhook payloads that trigger money, email, shipping, or account deletion.

If your team is still debating REST resources versus GraphQL operations versus event-driven workflows, revisit A Beginner's Guide to REST vs GraphQL APIs - When to Use Which. The best interface is not the trendiest one. It is the one your team can evolve, observe, and verify without needing a séance.

Where Teams Usually Step on Rakes

Contract testing is powerful, but it is not magic dust. You can absolutely do it badly.

The classic mistakes:

  • The provider team writes contracts based on what they hope consumers use.
  • Consumers generate contracts from mocks that do not match real client code.
  • Tests assert entire JSON blobs and break every time metadata changes.
  • Provider verification runs nightly, long after the breaking pull request merged.
  • Webhook examples live in docs but never run as executable verification.

The worst smell is a contract nobody owns. Each consumer should own the contracts that describe its needs. Each provider should own verification. Both sides should agree on support windows, deprecation timelines, and what counts as breaking.

This is not bureaucracy. This is how you avoid the "who broke the app" meeting, which is just a blame-shaped standup with worse lighting.

Rollout Plan: Start Small or Suffer

Here is the boring plan that actually works:

  1. Pick one consumer and one provider with a history of integration bugs.
  2. Add contracts for three happy paths and three error paths.
  3. Run provider verification in CI as non-blocking for one week.
  4. Fix noisy matchers, missing state setup, and bad assumptions.
  5. Turn the check into a required gate.
  6. Add webhook contracts for important events.
  7. Expand to other consumers only after the first slice is stable.

Do not sell this internally as "we are building a contract testing platform." That sounds like a six-month meeting. Sell it as "we are preventing another Friday API surprise." People understand pain. Start there.

Wrapping It Up

API contract testing is the engineering equivalent of making services sign the agreement they were already pretending to follow. Unit tests prove your code works alone. Contract tests prove it still works with the systems depending on it.

Start with money, auth, onboarding, and webhook-driven workflows. Keep matchers strict about meaning but flexible about harmless change. Verify providers before deployment. Treat breaking contract failures as design feedback, not annoying test noise.

Combined with Best Practices for API Versioning and Backward Compatibility, A Beginner's Guide to REST vs GraphQL APIs - When to Use Which, and Reliable Webhook Delivery: Idempotent and Secure, contract testing gives TypeScript teams a very practical superpower: shipping API changes without making every consumer hold its breath.

Actionable Takeaways

  • Contract the behavior consumers actually use: required fields, status codes, error envelopes, pagination, auth expectations, and event identifiers.
  • Verify providers before deployment: contract tests belong in provider CI, where breaking changes can be fixed before rollout.
  • Version real breaking changes: if a contract fails because consumers depend on old behavior, migrate deliberately instead of hoping nobody notices.