Every API team has a postmortem that starts with "we didn't think that was a breaking change." A field that was always present becomes optional; an enum gains a value clients treat as exhaustive; a response code changes from 200 to 201; a query parameter that used to be ignored is now required. None of these show up in code review as dangerous, because code review shows the server diff, not the contract diff. The fix is boring and extremely effective: diff the OpenAPI document against the last released version in CI, classify changes automatically, and block the breaking ones. Here is how to set it up and what the classifier must understand.

What actually counts as breaking

Breaking means a change that can make a correctly written existing client fail. The list is longer than people expect:

ChangeBreaking?Why
Remove an endpointYesClient 404s
Remove a request parameterNo (usually)Extra params are tolerated
Add a required request parameter/fieldYesOld clients omit it
Make an optional request field requiredYesSame
Remove a response fieldYesClients read it
Add a response fieldNoAdditive
Make a response field optional/nullableYesClients dereference it
Narrow a type (integer → boolean)YesDecoding fails
Widen a request typeNoAccepts more
Remove an enum valueYesClients send/expect it
Add an enum value to a responseYes*Exhaustive switch/default-less clients break
Change status code semanticsYesBranching logic misses
Tighten validation (new max length)YesPreviously valid requests fail
Add an endpoint / optional paramNoAdditive
Change description textNoNarrative only

*Response enum additions are the subtle one: pure schema evolution says additive, but a client matching exhaustively on a closed set can crash. Conservative teams flag them as "attention required" rather than hard-blocking.

The CI gate

The pipeline compares the merged spec on the branch against the last released revision:

# 1. Resolve and bundle (multi-file refs must be followed)
npx @redocly/cli bundle openapi/openapi.yaml --output /tmp/candidate.yaml

# 2. Fetch the last released document (git tag, artifact registry, or hosted version)
curl -s https://api.example.com/openapi/2.4.0.yaml -o /tmp/base.yaml

# 3. Classify the diff
npx @redocly/cli lint /tmp/candidate.yaml
# (openapi-diff tooling reports breaking / non-breaking sections)
openapi-diff /tmp/base.yaml /tmp/candidate.yaml --fail-on breaking

The exact tool varies — openapi-diff (Java), oasdiff (Go, with a rich ruleset and GitHub Actions support), Redocly's lint and bundle with custom rules, and Spectral for style rather than compatibility. The mechanics that matter are the same: the base must be the released spec, not main; multi-file documents must be bundled identically on both sides; and the job needs a deliberate override path.

Design the override path

Not every flagged change should block forever — sometimes you must ship a breaking change with a version bump and a migration window. What you do not want is engineers disabling the check wholesale. Use explicit, reviewable suppression:

  • Breaking changes fail the build and print the affected operations with the exact rule (response-property-removed: data.legacyId).
  • An approved breaking change is recorded in a changelog entry in the same PR and requires an owner approval; the gate can read a label or a breaking: accepted marker attached to the change record.
  • The release process then bumps the API version (or date-based version) and the deprecation timeline starts.

This converts "breaking change" from a hidden fact into a recorded decision, which is the entire goal.

Deprecations before removals

The diff gate catches removals at the moment of removal, which is too late for consumers. Pair it with a deprecation policy expressed in the spec:

get:
  deprecated: true
  description: Use GET /v2/projects instead. Sunset 2027-01-15.
  responses:
    '200':
      headers:
        Deprecation:
          schema: { type: string }
          example: '@1768425600'
        Sunset:
          schema: { type: string }
          example: 'Thu, 15 Jan 2027 00:00:00 GMT'

RFC 8594 Deprecation and Sunset headers let observability detect who still calls deprecated operations. A useful CI/reporting job inverts the usual check: list deprecated operations with live traffic and no sunset date, and list operations past sunset still receiving calls.

Where teams get the comparison wrong

  • Diffing source layout instead of bundled output. A refactor that moves a schema between files looks like massive removal/addition when files are compared directly; bundling first makes the diff semantic.
  • Comparing against main instead of the release. Main already contains unreleased changes; the baseline must be what customers currently consume, tagged per version.
  • Ignoring examples and content types. Removing an example is not breaking, but removing a supported media type is; the ruleset must cover content keys, security requirements, and parameter in locations.
  • Forgetting webhooks and SSE event contracts. Removing a webhook event type or an SSE event name is as breaking as removing a response field; treat the event catalog as part of the diff.
  • Generated SDKs without the gate. Client generation hides nothing; a removed field breaks the union type at compile time, which is the same signal arriving later. Run the diff first, where the message is actionable.

Style linting is the other half

Compatibility diffing answers "does this break clients"; style linting answers "does this match how we design APIs." A Spectral or Redocly ruleset enforces naming conventions (snake_case properties, verb-first operationIds), required descriptions and examples, consistent pagination envelopes, the problem+json error schema, and tag hygiene. Run both: style rules keep new operations from inventing a fourth pagination style; the diff gate protects existing consumers. Together they make API review a ten-minute exception review instead of an hour of re-deriving the contract from implementation code.

Making the spec trustworthy enough to gate on

A CI gate is only as good as the document's relationship to reality. Two closing practices:

  1. Verify the service against the spec. Replay recorded traffic or run contract tests against staging and validate responses; a spec that omits fields the API actually returns produces false confidence.
  2. Recover drift from code when needed. For services where the spec was neglected, an AST-based code scan can regenerate candidate operations and schemas for review, closing gaps the diff tool cannot see.

In Powerduck the spec is the working document — designed, debugged, mocked, and tested in one place — so the compatibility diff is a natural step before publishing a revision, with webhooks and SSE events included in the change report alongside HTTP operations. The hosted side keeps versioned revisions, which gives the CI job a stable "last released" baseline.

What to read next: Swagger 2.0 to OpenAPI 3.x migration is often the first time a team establishes a release baseline, and generate a TypeScript client from OpenAPI shows the compile-time second gate on the consumer side.