API Breaking Changes Without Breaking Clients 2026
How to Handle API Breaking Changes Without Breaking Clients
Your API will change. New features, bug fixes, security patches, and architecture evolution require API modifications. The challenge: making changes without breaking the applications that depend on your current API. Here's how companies like Stripe, GitHub, and Twilio evolve their APIs without causing outages.
TL;DR
- A breaking change is any modification that causes existing, working client code to fail — understand this precisely before touching anything
- Additive-only changes (new fields, new endpoints, new optional params) are safe to deploy at any time
- Deprecation before removal requires 6-12 months' notice minimum, Sunset headers on every response, and proactive developer outreach
- Automated breaking change detection with oasdiff in CI catches regressions before they ship
- Stripe's version pinning model is the gold standard but requires compatibility layers for every historical version
What Is a Breaking Change?
A breaking change is any modification that causes existing, working client code to fail.
Breaking (requires client changes)
| Change | Why It Breaks |
|---|---|
| Remove a field from response | Clients parsing that field get errors |
| Rename a field | Same as removing |
| Change a field's type | "price": "49.99" → "price": 4999 |
| Remove an endpoint | Clients calling it get 404 |
| Add a required parameter | Existing requests without it fail |
| Change authentication method | Existing credentials stop working |
| Change error response format | Client error handling breaks |
| Change status code meaning | 200 → 201 for creation |
Non-Breaking (safe to deploy)
| Change | Why It's Safe |
|---|---|
| Add a new optional field to response | Clients ignore unknown fields |
| Add a new endpoint | Existing clients don't call it |
| Add an optional parameter | Existing requests work without it |
| Add a new enum value | Clients handle unknown values gracefully |
| Improve error messages | Machine-readable codes stay the same |
| Add a new HTTP method to existing endpoint | Existing calls still work |
Strategies for Safe Evolution
1. Additive-Only Changes
The safest strategy: only add, never remove or modify. New fields, new endpoints, new optional parameters. Existing clients continue working unchanged.
Rule: If your OpenAPI spec diff only shows additions, it's safe.
2. Deprecation Before Removal
Never remove a field or endpoint without warning. Follow this timeline:
- Announce deprecation — add
Deprecatedheader, update docs, notify via email/changelog - Migration period — 6-12 months minimum (Stripe uses 12 months)
- Usage monitoring — track which clients still use deprecated features
- Direct outreach — contact active users of deprecated features
- Remove — only after usage drops to near-zero
3. Sunset Header (RFC 8594)
Sunset: Sat, 08 Mar 2027 00:00:00 GMT
Deprecation: Sat, 08 Mar 2026 00:00:00 GMT
Link: <https://api.example.com/docs/migration>; rel="sunset"
Include in responses for deprecated endpoints. Automated tools can detect and alert on these headers.
4. Response Field Deprecation
Mark fields as deprecated in your response without removing them:
{
"name": "John",
"email": "john@example.com",
"username": "john_doe",
"_deprecated_fields": ["username"]
}
Or use your OpenAPI spec to mark fields as deprecated, which shows in generated documentation and SDKs.
5. Stripe's Approach: API Version Pinning
Stripe pins each account to the API version it first used. New accounts get the latest version. Existing accounts stay on their version until they explicitly upgrade.
Stripe-Version: 2024-12-18
How it works:
- Each API version has a fixed response format
- Breaking changes create a new version
- Account is pinned to a version
- Dashboard shows available upgrades with changelogs
- Client can override version per-request for testing
This is the gold standard but requires maintaining compatibility layers for every version.
6. Feature Flags
Instead of versioning the entire API, toggle individual features:
X-Feature: new-pricing-model
Clients opt in to new behavior on a per-feature basis. Less overhead than full API versioning.
Communication Patterns
Changelog
Maintain a dated changelog of all API changes:
## 2026-03-08
### Added
- `order.metadata` field in order responses
- `GET /api/v1/orders/search` endpoint
### Deprecated
- `order.notes` field (use `order.metadata` instead, removal: 2027-03-08)
### Fixed
- `order.total` now correctly includes tax for EU orders
Migration Guides
For every breaking change, provide a migration guide:
- What changed and why
- Before/after code examples
- Step-by-step migration instructions
- Timeline (when old behavior stops working)
- Support contact for questions
SDK Updates
Release SDK updates that handle the migration. Ideally, updating the SDK version is all clients need to do.
Monitoring Deprecation Usage
Track which clients use deprecated features:
SELECT api_key, endpoint, count(*)
FROM api_logs
WHERE endpoint IN (deprecated_endpoints)
AND timestamp > NOW() - INTERVAL '30 days'
GROUP BY api_key, endpoint
ORDER BY count DESC;
Proactively reach out to clients still using deprecated features before removal.
The Cost of Breaking Changes
The developer community has a long memory for APIs that break their integrations without warning. The Twilio SMS API's transition from version-less endpoints to v1 URLs in 2010 caused production outages for hundreds of developers who had not been adequately notified. Those developers wrote about it, and the "Twilio breaking change" story circulated for years in developer communities. Trust, once broken, is expensive to rebuild.
The financial cost of breaking changes is real and measurable. Every developer who integrates your API is implicitly betting engineering time on your API's stability. When you break them, they spend time debugging, often blaming their own code first (wasting hours before discovering the API changed), then migrating, then re-testing. For a company with 500 API integrations, a single breaking change can consume thousands of hours across the ecosystem. The negative reviews, churn, and lost recommendations multiply that cost further.
Well-managed API evolution, by contrast, is a competitive moat. Stripe's commitment to backward compatibility is a major reason enterprises trust it with their payment infrastructure. When Salesforce announced they would support their API versions for a minimum of three years, enterprise customers took note. Predictability is valuable — developers factor it into their technology choices.
Estimating integration maintenance cost: A rough model is $500-2,000 per integration per breaking change (assuming 2-8 hours of developer time plus testing, deploy, and support overhead). Multiply by your integration count. For APIs with thousands of integrations, a poorly managed breaking change can cost millions of dollars of developer time across your ecosystem — not counting the reputational damage.
The ROI of investing in backward compatibility and clean deprecation tooling is overwhelming. An automated breaking change detector in CI that costs a week to set up can prevent a breaking change that would cost your users 10,000 hours of migration work.
Versioning Implementation Patterns
URL path versioning (/v1/, /v2/) is the most common approach and the easiest for developers to understand. When you deploy a new version with breaking changes, the old version continues to function at its original URL. Here is how to implement dual-version routing in Express or Hono:
// Hono: routing by API version
import { Hono } from 'hono';
const app = new Hono();
// Shared middleware applies to all versions
app.use('*', authMiddleware);
// Version 1 routes
const v1 = new Hono();
v1.get('/users/:id', async (c) => {
const user = await getUser(c.req.param('id'));
// v1 response format: { name: string, email: string }
return c.json({ name: user.displayName, email: user.email });
});
// Version 2 routes — new response shape
const v2 = new Hono();
v2.get('/users/:id', async (c) => {
const user = await getUser(c.req.param('id'));
// v2 response format: { id, display_name, contact: { email } }
return c.json({
id: user.id,
display_name: user.displayName,
contact: { email: user.email },
});
});
app.route('/v1', v1);
app.route('/v2', v2);
Code sharing between versions is the operational challenge. Both /v1/users and /v2/users read from the same database and share the same business logic — only the response transformation differs. The pattern is to keep your data access layer and business logic version-agnostic, and apply version-specific transformations at the serialization layer:
// Shared business logic
async function getUserById(id: string): Promise<UserEntity> {
return db.users.findUnique({ where: { id } });
}
// Version-specific serializers
function serializeUserV1(user: UserEntity) {
return { name: user.displayName, email: user.email };
}
function serializeUserV2(user: UserEntity) {
return {
id: user.id,
display_name: user.displayName,
contact: { email: user.email },
created_at: user.createdAt.toISOString(),
};
}
When to deprecate a version: Deprecate when adoption of the new version reaches 80%+ and when active usage of the old version drops below 5% of total requests. Never deprecate based on calendar time alone — base it on actual usage metrics. Set a hard sunset date only after you've confirmed all major integrations have migrated, and give at least 6 months' notice.
For a deeper treatment of versioning strategy, see our guide on how to version REST APIs in 2026.
Using oasdiff for Breaking Change Detection
Manual review of API changes for breaking changes is error-prone. The same change that seems safe (renaming a field in a response) can break dozens of integrations. oasdiff is an open-source tool that compares two OpenAPI specifications and reports breaking changes with high accuracy.
Install and run locally:
# Install
brew install oasdiff
# Compare two OpenAPI specs
oasdiff breaking ./openapi-v1.yaml ./openapi-v2.yaml
# Output example:
# GET /users/{id}: response property 'username' removed from '200' response (breaking)
# POST /orders: request property 'shipping_address' became required (breaking)
Integrate into your GitHub Actions CI pipeline so every PR that touches your API spec is automatically checked for breaking changes:
# .github/workflows/api-breaking-changes.yml
name: API Breaking Change Check
on:
pull_request:
paths:
- 'openapi.yaml'
- 'openapi/**'
jobs:
check-breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get base branch spec
run: git show origin/main:openapi.yaml > /tmp/openapi-base.yaml
- name: Install oasdiff
run: |
curl -L https://github.com/tufin/oasdiff/releases/latest/download/oasdiff_linux_amd64.tar.gz | tar xz
sudo mv oasdiff /usr/local/bin/
- name: Check for breaking changes
run: |
oasdiff breaking /tmp/openapi-base.yaml openapi.yaml --fail-on WARN
The --fail-on WARN flag causes the CI step to fail if any breaking changes are detected. This prevents breaking changes from being merged accidentally. You can configure oasdiff with an exceptions file for changes you've deliberately decided to make as breaking (with proper deprecation planning), so the check doesn't block intentional breaking releases.
What oasdiff catches: removed endpoints, removed or renamed request/response fields, changed field types, changed required/optional status of fields, removed enum values, changed authentication schemes, and more. It does not catch semantic breaking changes (same schema but different behavior) — those require integration tests.
Field-Level Deprecation in OpenAPI
OpenAPI's deprecated: true property can be applied to operations, parameters, and schema properties. Leveraging this properly creates a trail through your documentation and SDKs that guides developers toward current patterns.
components:
schemas:
User:
type: object
properties:
id:
type: string
name:
type: string
deprecated: true
description: "Deprecated: Use `display_name` instead. Will be removed 2027-03-08."
x-sunset: "2027-03-08"
display_name:
type: string
description: "The user's display name. Replaces the deprecated `name` field."
email:
type: string
The deprecated: true field shows in rendered documentation (Swagger UI, Redoc) with a visual indicator. Generated SDKs — particularly TypeScript SDKs generated by tools like openapi-typescript or @openapitools/openapi-generator — surface deprecation warnings at compile time:
// TypeScript SDK generated from OpenAPI spec
user.name; // TypeScript warning: 'name' is deprecated. Use 'display_name' instead.
user.display_name; // No warning — this is the current field
The x-sunset extension (not part of the OpenAPI standard but widely recognized by generators) lets you specify the date the field will be removed. Some code generators convert this into deprecation notices with the sunset date included, so developers know exactly how long they have to migrate.
Migration guides should link directly from the deprecated field's documentation. A developer who clicks on the deprecated name field in your API reference should land on a page that says: what the replacement is, how to update their code, and when the old field will be removed. Making migration as low-friction as possible is the difference between a deprecation that succeeds and one that requires emergency support.
Multi-Version SDK Strategy
Maintaining two API versions without a clear SDK strategy creates confusion for developers who do not want to think about API version numbers — they want their SDK to handle it. A clean multi-version SDK approach minimizes developer burden during transitions.
The recommended pattern is a single SDK package with version-specific clients and shared types where possible:
// @your-org/sdk
import { createClient } from '@your-org/sdk';
// V1 client
const clientV1 = createClient({ version: 'v1', apiKey: '...' });
const user = await clientV1.users.get('123');
// TypeScript type: { name: string; email: string }
// V2 client
const clientV2 = createClient({ version: 'v2', apiKey: '...' });
const userV2 = await clientV2.users.get('123');
// TypeScript type: { id: string; display_name: string; contact: { email: string } }
The SDK injects the appropriate version header automatically based on which client factory was used. Developers who create a v1 client cannot accidentally call v2 endpoints that have different response shapes.
Deprecation warnings in the SDK itself add value beyond OpenAPI annotations. When a developer calls a deprecated method, the SDK can emit a console.warn with migration guidance:
class UsersV1Client {
async get(id: string) {
console.warn(
'[Deprecated] users.get() in v1 is deprecated. ' +
'Migrate to v2 client: https://docs.example.com/migration/v1-to-v2. ' +
'Sunset date: 2027-03-08'
);
return this.http.get(`/v1/users/${id}`);
}
}
Per-version TypeScript type definitions are the most valuable part of a multi-version SDK. Developers get type-safe access to exactly the fields their version exposes, and TypeScript errors at the type level (not runtime) when they try to access a field that doesn't exist in their version. This makes upgrading to a new version a refactoring exercise driven by compiler errors, which is the most pleasant migration experience possible.
For guidance on building SDKs developers actually use, see our guide on how to build an API SDK developers use.
Conclusion
API evolution without breaking clients is a discipline that requires investment in tooling, communication, and process — not just technical skill. Automated breaking change detection catches mistakes before they ship. Long deprecation windows and proactive developer outreach give integrators time to migrate. Version pinning (for high-value integrations) and feature flags (for lower-stakes changes) give you flexibility in how you manage the transition.
The fundamental contract with your API users is that their integrations will continue working. Honor that contract consistently, and you build the trust that turns API consumers into long-term platform partners.
Related: How AI Is Transforming API Design and Documentation, API Caching Strategies: HTTP to Redis 2026, API Changelog & Versioning Communication 2026