How to Design APIs That Never Break Their Clients
api backend architecture
How to Design APIs That Never Break Their Clients
Versioning strategies, backward compatibility rules, and real-world patterns from the trenches
π In this post
The Incident
Itβs 11 PM on a Thursday. Priya, a backend engineer at a mid-sized fintech, merges what looks like a routine PR. The change? Renaming a JSON field: user_name β username. Clean, consistent, follows the new style guide. CI passes. Deploy goes out.
By midnight, 3 enterprise clients are paging their on-call engineers. Their mobile apps - which parse user_name - are now showing empty profile screens for every user.
By 1 AM, Priya is rolling back. By 2 AM, sheβs drafting a post-mortem. By morning, the BD team is fielding angry calls.
- A story every API engineer eventually lives through
This isnβt a hypothetical. Itβs a pattern that plays out at companies of every size - from early-stage startups to Stripe, Twilio, and GitHub. The root cause is always the same: the API was designed to be changed, not to be evolved.
Thereβs a difference. And this post is about that difference.
What Even Counts as a Breaking Change?
Before we talk solutions, letβs sharpen our intuition. Not every change breaks clients. But some changes that look harmless absolutely do.
β Breaking Changes
// Renaming a field
{ "user_name": "anshuman" }
β
{ "username": "anshuman" }
// Removing a field entirely
{ "user_name": "anshuman", "email": "..." }
β
{ "username": "anshuman" } // email gone!
// Changing a field's type
{ "age": "27" } // was string
β
{ "age": 27 } // now number
// Changing HTTP status codes
GET /users/999
was: 200 { "user": null }
now: 404 β clients checking for 200 break
// Restructuring the response shape
{ "data": { "id": 1 } }
β
{ "result": { "user_id": 1 } }
// Adding required fields to request body
POST /orders
was: { "item_id": 1 }
now: { "item_id": 1, "warehouse": "required!" }β Safe (Non-Breaking) Changes
// Adding optional fields to response
{ "id": 1, "name": "Anshuman" }
β
{ "id": 1, "name": "Anshuman",
"avatar_url": "https://..." } // new, optional
// Adding optional fields to request
POST /orders
{ "item_id": 1 } // old clients still work
{ "item_id": 1, "note": "gift wrap" } // new field optional
// Adding new endpoints entirely
GET /v1/users/:id // unchanged
POST /v1/users/:id/avatar // brand new
// New enum values (with caveats)
{ "status": "active" | "inactive" }
β
{ "status": "active" | "inactive" | "pending" }
// β οΈ only safe if clients use default/fallback
// Relaxing validation rules
was: name max 50 chars
now: name max 200 charsβ οΈ The βnew enum valueβ trap Adding a new enum value looks safe but breaks clients that use exhaustive switch/if-else matching without a default. Always document that clients must handle unknown enum values gracefully.
The mental model: think of your API as a contract. Your clients write code that depends on the exact shape of your response. Every field name, every type, every status code - itβs all load-bearing. Changing any of it is demolishing a wall without checking if itβs structural.
The 4 Versioning Strategies
There are four main ways to version an API. Each is a different answer to the question: βWhere does the version live?β
Strategy Deep-Dive: URL Path Versioning
This is what most teams use, and for good reason. Letβs look at what it actually means in practice.
# v1 - original
GET /api/v1/users/42
Response:
{
"user_name": "anshuman",
"email": "anshuman@example.com"
}
# v2 - new shape, runs in parallel
GET /api/v2/users/42
Response:
{
"username": "anshuman",
"contact": {
"email": "anshuman@example.com",
"phone": null
}
}
Both run simultaneously. Old clients keep hitting /v1. New clients use /v2. The URL is the contract, and the version is part of the contract.
The Stripe Approach: Header Versioning with Date Pins
Stripe is the gold standard for API design. Their versioning is subtle but brilliant:
POST https://api.stripe.com/v1/charges
Stripe-Version: 2024-06-20
Authorization: Bearer sk_live_...
Thereβs only one βversionβ in the URL (/v1/) and itβs been there forever. The actual version is passed as a date header. Every API key has a version anchor - the date of the Stripe version active when you created the key.
This means:
- You never have to upgrade unless you want to
- You opt-in to breaking changes deliberately
- Stripe can evolve aggressively without breaking anyone
The Golden Rules of API Evolution
- Never remove a field - deprecate it first
Mark fields with a
deprecated: trueflag in your OpenAPI spec. Add aDeprecationresponse header. Document the sunset date. Run in parallel for at least 6 months. - Only add, never remove or rename
Additive changes are always safe. Old clients ignore fields they donβt understand. New clients get richer responses. This is the Robustness Principle in action: βbe conservative in what you send, liberal in what you accept.β
- Treat your API schema like a database migration
Would you drop a column with live traffic on it? No - youβd use the Expand/Contract pattern: add the new column, migrate data, dual-write to both, then eventually remove the old one. Same logic applies to API fields.
- Never change a fieldβs type
Changing
βageβ: β27βtoβageβ: 27seems harmless. Itβs not. Clients that pass this to a typed function will throw a runtime error. If you must, add a new field:age_int, deprecate the old one. - Extend enums carefully
Adding a new enum value is a βsoftβ breaking change. Any client with exhaustive pattern matching (TypeScript discriminated unions, Swift enums, Kotlin sealed classes) will fail at compile time or throw at runtime. Document that clients must handle unknown values.
- Document your stability guarantees explicitly
Tell your consumers what they can count on. Kubernetes does this with
alpha/beta/stablelabels. Twitterβs API has βfrozenβ endpoints that donβt change. Make the contract explicit so clients know what to trust. - Version from day one, not day one-hundred
The cost of adding
/v1/to your routes on day one is near zero. The cost of retrofitting versioning after you have 50 production clients is enormous - and painful for everyone.
The Expand/Contract Pattern for API Evolution
This is the single most useful pattern for evolving APIs without breakage. It mirrors database migration strategy and it works at the API level too.
Phase 1 - EXPAND: add the new thing alongside the old
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Response:
{
"user_name": "anshuman", β old field, still here
"username": "anshuman", β new field, added
"email": "..."
}
Phase 2 - MIGRATE: move clients to the new field
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Update your SDKs and docs to use "username"
- Alert power users / enterprise clients
- Track usage of "user_name" in your analytics
- Wait until "user_name" usage drops to near zero
Phase 3 - CONTRACT: remove the old thing
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Once usage is negligible, remove "user_name"
- Announce the sunset date well in advance
- Log a warning for any client still sending "user_name"
β Real-world timeline The expand phase can last days. The contract phase should wait months. Stripe gives at least 6 months notice for deprecations. For high-scale public APIs with enterprise clients, a year is not unreasonable.
Deprecation Without Drama
Deprecation is not just about deleting code - itβs a communication strategy. Hereβs how to do it well.
In your OpenAPI/Swagger spec:
/users/{id}:
get:
responses:
'200':
content:
application/json:
schema:
properties:
user_name:
type: string
deprecated: true # β marks it in the spec
description: "Deprecated. Use `username` instead. Will be removed 2027-01-01."
username:
type: string
In your response headers:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: <https://api.yourco.com/docs/migration/v2>; rel="deprecation"
In your error logs:
// Log a warning when deprecated field is accessed
if (clientUsedDeprecatedField) {
logger.warn({
event: 'deprecated_field_access',
field: 'user_name',
clientId: req.clientId,
sunset: '2027-01-01'
});
}
This gives you the data to know exactly which clients are still using the old field - so you can reach out to them proactively rather than surprising them.
Strategy Comparison
| Strategy | Discoverability | Cacheable | Browser Testable | Used By | Best For |
|---|---|---|---|---|---|
| URL Path (/v1/) | β High | β Yes | β Yes | GitHub, Twitter, Reddit | Public REST APIs |
| Query Param (?v=1) | ~ Medium | ~ Varies | β Yes | Google APIs, internal tools | Internal/simple APIs |
| Custom Header | β Low | β Yes (Vary) | β Needs curl | Stripe, AWS | Developer platforms |
| Accept Header | β Low | ~ Varies | β Needs curl | GitHub (media types) | Purist REST |
| GraphQL Evolution | β High | ~ Complex | ~ GraphiQL | Facebook, Shopify | Flexible query APIs |
The Versioning Lifecycle Diagram
π‘ Why HTTP 410 and not 404? 404 means βIβve never heard of this.β 410 means βI know what youβre asking for, and itβs intentionally gone.β This distinction matters for clients that auto-retry on 404 - they wonβt do the same on 410.
Real-World Example: Evolving a User Profile Endpoint
Letβs walk through a concrete evolution scenario end-to-end.
The starting point:
GET /api/v1/users/42
{
"user_name": "anshuman",
"full_name": "Anshuman Singh",
"email": "anshuman@example.com"
}
The desired end state (after product decides to restructure):
{
"username": "anshuman",
"profile": {
"display_name": "Anshuman Singh",
"contact": {
"email": "anshuman@example.com"
}
}
}
The wrong way: rename everything, merge to main, deploy. Wake up to PagerDuty alerts.
The right way:
Step 1 - EXPAND (Week 1): Serve both shapes simultaneously
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
{
"user_name": "anshuman", // old, still here
"username": "anshuman", // new, added
"full_name": "Anshuman Singh", // old
"profile": {
"display_name": "Anshuman Singh"
},
"email": "anshuman@example.com", // old
"_meta": {
"deprecated_fields": ["user_name", "full_name", "email"],
"sunset_date": "2027-03-01"
}
}
Step 2 - COMMUNICATE (Week 2): Notify clients
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Update changelog
- Send email to API key holders
- Add Deprecation + Sunset headers
- Track usage of old fields in your metrics
Step 3 - CONTRACT (Month 6+): Remove when usage is <1%
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Remove old fields. Celebrate.
Frequently Asked Questions
@deprecated, and use the introspection system so clients can discover whatβs available. The GitHub GraphQL API, for example, has been running on a single version since launch. The tradeoff is that schema evolution requires more discipline - you can never remove a field until usage drops to zero.schema_version field in every webhook payload so clients can route to the right handler; (2) let clients subscribe to a specific version (Stripeβs approach - your webhook config includes a version pin); (3) maintain a webhook envelope format that stays stable while the data field evolves. Never remove fields from webhook payloads without a long deprecation window.Interview Questions
These come up regularly in system design rounds, especially at FAANG-tier and late-stage startups.
user_name get undefined when itβs renamed to username. (2) Changing a fieldβs type - from string to number or vice versa causes type errors in typed clients. (3) Removing a required field from a request body - while old clients send it, new validation that rejects it will break them. Contrast with additive changes (new optional fields, new endpoints), which are safe./v1/, /v2/) is highly discoverable, cacheable by CDNs, easy to test in a browser, and the dominant industry choice for public APIs (GitHub, Reddit, Twitter). The downside is URL proliferation and separate codepaths per version. Header versioning (e.g., API-Version: 2024-01-01 like Stripe) keeps URLs clean and is excellent for platforms where clients pin to a version for months or years. The downside is lower discoverability and harder curl-based debugging. Choose URL versioning for public developer APIs where discoverability matters; choose header versioning for sophisticated developer platforms where stability guarantees over long periods are the primary concern.deprecated: true to your OpenAPI spec, include Deprecation and Sunset response headers on every affected response, link to a migration guide. Communication: email all API key holders with sunset timeline, post a changelog entry, update SDK docs. Monitoring: log every use of deprecated endpoints/fields with the client ID - this tells you exactly who still needs to migrate. Enforcement: 30 days before sunset, consider returning a soft warning in the response body. On sunset date, return HTTP 410 Gone (not 404) with a helpful error body pointing to the migration guide. Keep the 410 response running for at least 3 months so latecomers get a clear signal. For enterprise clients, offer migration support contracts.@deprecated β monitor usage β remove pattern. The tradeoff: schema evolution requires much stricter discipline. You can never remove a field until you can prove itβs not being queried - which requires query-level analytics on your schema. You also lose the ability to do clean breaking rewrites behind a new version number. For teams that are willing to invest in schema governance, GraphQLβs approach scales beautifully. For teams that want explicit version boundaries (e.g., βv2 is completely differentβ), REST versioning is more pragmatic.{ "event_type": "...", "schema_version": "2", "data": {...} }. The envelope never changes; only the data object evolves, and itβs versioned separately. (2) Per-subscription version pinning (Stripeβs model) - each webhook subscription is pinned to a schema version; clients opt-in to new versions by updating their subscription. (3) Additive-only payload changes - treat webhook payloads like API responses: only add fields, never remove or rename. (4) Idempotency keys - include an event ID so consumers can safely retry on failure. The most important thing: never change the type or name of a field in a webhook payload without the full Expand/Contract cycle.The Mental Model to Take Away
Priyaβs 2 AM incident could have been prevented with one shift in thinking: treat your API as a published contract, not a private implementation detail.
Your clients - whether theyβre a mobile app, a third-party integration, or another teamβs microservice - write real code against your API. Every field name is a variable name in their codebase. Every status code is a branch in their error handler.
When you evolve an API:
- Add, donβt replace. New fields can coexist with old ones.
- Deprecate loudly, remove quietly. Give clients time, data, and a migration guide.
- Version from day one.
/v1/costs nothing to add on day one and everything to retrofit later. - Measure before you remove. Use your own logs to know when itβs safe to cut the old field.
- Return 410, not 404. Signal intent, not absence.
The best API is one that clients barely notice evolving - because it does so gracefully, predictably, and always with them in mind.