Designing a REST API is easy. Designing one you can still change in three years, while other people's software depends on it, is the actual problem. Every field you return is a promise, every status code is a branch in someone else's error handling, and the moment a third party integrates, your internal data model has become a public contract whether you meant it to or not.
This is about the four decisions that determine whether that contract survives: how you version, how you paginate, how you report errors, and how you describe and enforce the whole thing. None of them are hard individually. Getting them wrong is expensive in a way that is hard to reverse.
First: what actually counts as breaking
Most versioning arguments are really disagreements about this question. Get the definition right and you will need to version far less often than you think.
| Safe to add or change | Breaking |
|---|---|
| Adding a new optional field to a response | Removing a field, or renaming one |
| Adding a new optional request parameter | Making an optional parameter required |
| Adding a new endpoint | Changing a field's type or format |
| Adding a new enum value to a request | Adding an enum value to a response |
| Adding a new error code within an existing status | Changing which status code a condition returns |
| Relaxing a validation rule | Tightening one |
| Adding a new optional header | Changing default behaviour when a parameter is omitted |
There is a second, harder category: behaviour nobody documented but clients depend on anyway. Result ordering you never promised. A field that has always happened to be non-null. Timing that was always fast. This is Hyrum's Law - with enough consumers, every observable behaviour of your system becomes something somebody relies on. You cannot prevent it; you can only reduce the surface by documenting what is guaranteed and deliberately varying what is not.
Resource design and HTTP semantics
Before any of the four topics, the basics have to be right, because everything else compounds on them.
GET /orders # collection
POST /orders # create
GET /orders/{id} # single resource
PATCH /orders/{id} # partial update
PUT /orders/{id} # full replacement
DELETE /orders/{id}
GET /orders/{id}/items # sub-collection
# Actions that are not CRUD get a sub-resource, not a verb in the path
POST /orders/{id}/cancellation # good
POST /orders/{id}/cancel # acceptable, widely used
POST /cancelOrder?id=42 # not REST, and harder to cache or routeStatus codes that mean something
| Code | Use it when |
|---|---|
200 OK | Success with a body |
201 Created | A resource was created. Include a Location header |
202 Accepted | Work was queued. Return something the client can poll |
204 No Content | Success with nothing to say - typically DELETE |
400 Bad Request | Malformed syntax - unparseable JSON, missing required field |
401 Unauthorized | No credentials, or invalid ones. Actually means unauthenticated |
403 Forbidden | Authenticated, but not allowed |
404 Not Found | No such resource - or a resource this caller may not know exists |
409 Conflict | State conflict - duplicate, version mismatch, illegal transition |
410 Gone | It existed and is permanently removed. Useful for retired endpoints |
422 Unprocessable Content | Syntactically valid, semantically wrong - failed business validation |
429 Too Many Requests | Rate limited. Include Retry-After |
Idempotency
GET, PUT and DELETE are idempotent by definition. POST is not, which is a problem the moment a client retries a payment after a timeout. The standard solution is a client-supplied key.
POST /payments HTTP/1.1
Idempotency-Key: 8f14e45f-ea8f-4b2a-9a1f-3c2d1e0b7a55
Content-Type: application/json
{ "order_id": "ord_42", "amount_cents": 4999 }- Store the key with the response you returned, scoped to the authenticated caller.
- On a repeat with the same key and the same request body, return the stored response without re-executing.
- On a repeat with the same key and a *different* body, return
422- that is a client bug, not a retry. - Expire keys after a defined window, typically 24 hours, and say so in your documentation.
- While the first request is still in flight, return
409rather than starting a second one.
Errors
Error design is where APIs most often fail their consumers, and it is the cheapest of the four to get right, because there is a standard for it.
Problem Details, RFC 9457
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Request validation failed",
"status": 422,
"detail": "The order could not be created because two fields are invalid.",
"instance": "/orders",
"trace_id": "01J9XQ7K3M2P8V",
"errors": [
{
"pointer": "/items/0/quantity",
"code": "below_minimum",
"detail": "Quantity must be at least 1."
},
{
"pointer": "/shipping/postcode",
"code": "invalid_format",
"detail": "Postcode must be four digits."
}
]
}type, title, status, detail and instance are the standard members. Everything else - errors, trace_id - is a documented extension, which the RFC explicitly allows.The type URI is the machine-readable identifier and the part that matters most. Clients branch on it, not on the human-readable title, which means you can rewrite your error prose for clarity without breaking anyone. Make the URI resolve to a page explaining the error and how to fix it - that page is documentation you only write once.
Rules that hold across every error
- Return every validation error at once, not the first one. A form that fails five times in sequence is a bad experience someone will blame on your API.
- Use a stable machine code per error, separate from the message.
insufficient_stockis a contract; "Not enough items in stock" is a string you will want to change. - Point at the field. A JSON Pointer (
/items/0/quantity) lets a client attach the message to the right input without parsing prose. - Include a correlation ID in every error response and in your logs. "What went wrong?" becomes a one-line lookup instead of an investigation.
- Never leak internals. No stack traces, no SQL, no upstream hostnames, no library names. Log those; return an opaque reference.
- Keep the status code honest. An error inside a
200body is the single most common way to break clients that trust HTTP.
// The anti-pattern, and it is everywhere
HTTP/1.1 200 OK
{ "success": false, "error": "Order not found" }
// Every retry library, cache, monitor and load balancer now believes this succeeded.Pagination
Every collection endpoint needs pagination from day one, including the ones you are sure will stay small. Retrofitting it is a breaking change, because clients that received everything now silently receive the first page.
Offset versus cursor
| Offset / page | Cursor / keyset | |
|---|---|---|
| Looks like | ?page=3&per_page=20 | ?limit=20&cursor=eyJpZCI6ODc0fQ |
| Jump to an arbitrary page | Yes | No |
| Total count available | Cheap | Expensive or omitted |
| Performance at depth | Degrades linearly - page 5,000 scans 100,000 rows | Constant, whatever the depth |
| Stable while data changes | No - inserts shift everything, causing duplicates and skips | Yes |
| Good for | Admin tables, small collections, page-number UIs | Feeds, exports, sync, anything large or live |
The correctness argument is stronger than the performance one. With offset pagination on a collection sorted newest-first, a row inserted between requests pushes everything down by one - so the client sees an item twice and misses another entirely. For an export or a sync job that is silent data corruption.
-- Offset: the database counts and discards 100,000 rows to give you 20
SELECT * FROM orders ORDER BY placed_at DESC LIMIT 20 OFFSET 100000;
-- Keyset: an index seek straight to the position, then 20 rows
SELECT * FROM orders
WHERE (placed_at, id) < ($1, $2)
ORDER BY placed_at DESC, id DESC
LIMIT 20;placed_at alone means rows sharing a timestamp can be duplicated or skipped across page boundaries.A response shape that can grow
GET /orders?limit=20&status=paid HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [
{ "id": "ord_874", "status": "paid", "total_cents": 4999 }
],
"pagination": {
"next_cursor": "eyJwbGFjZWRfYXQiOiIyMDI2LTA4LTE0VDA5OjE0OjAwWiIsImlkIjo4NzR9",
"prev_cursor": null,
"limit": 20,
"has_more": true
}
}- Treat the cursor as opaque. Base64 of a JSON object is fine, but document that clients must not decode or construct it - that is what keeps you free to change its contents.
- Sign or validate cursors if they encode anything a caller should not control. An unvalidated cursor is a parameter an attacker can edit.
- Cap the page size and say what the cap is. Return the effective limit in the response rather than silently clamping.
- Make total counts optional -
?include_total=true. On a large filtered table,COUNT(*)can cost more than the page itself. - Keep sort order deterministic. Non-deterministic ordering makes pagination unreliable regardless of which style you chose.
For very large exports, offer a different mechanism entirely rather than expecting clients to walk ten thousand pages: a bulk export job that returns a download URL, or a sync endpoint that accepts a since watermark.
Versioning
The most-argued and least-important of the four, in the sense that any of the common approaches works if your change discipline is good, and none of them saves you if it is not.
| Approach | Looks like | Trade-off |
|---|---|---|
| URL path | /v2/orders | Obvious, easy to route and cache, trivially testable in a browser. Purists object that the resource identity should not change; in practice this is the most common choice for good reasons |
| Custom header | X-API-Version: 2 | Clean URLs, but invisible in logs and browser testing, and easy to forget to send |
| Media type | Accept: application/vnd.example.v2+json | The most technically correct. Also the most awkward for consumers, and poorly supported by tooling |
| Date-based | X-API-Version: 2026-08-14 | Stripe's approach. Pins a client to a point in time; excellent for gradual change, and real work to implement |
| No versioning | Additive change only, forever | Viable for internal APIs with coordinated deploys. Not viable with third-party consumers |
Recommendation: major version in the URL path, additive changes within it. /v1 changes only in backward-compatible ways; anything breaking waits for /v2. This is understandable to everyone, easy to route at the edge, and does not require your consumers to learn anything.
Deprecating properly
There are standard headers for this, and almost nobody uses them. Deprecation (RFC 9745) says the resource is deprecated and optionally when that took effect. Sunset (RFC 8594) says when it stops working. Together they let a client detect the problem automatically rather than reading a blog post.
HTTP/1.1 200 OK
Deprecation: @1767225600
Sunset: Sat, 01 Aug 2026 00:00:00 GMT
Link: <https://docs.example.com/migrations/v2>; rel="deprecation"; type="text/html",
<https://api.example.com/v2/orders>; rel="successor-version"
Content-Type: application/jsonDeprecation carries a timestamp as a structured field. Sunset uses an HTTP date. The Link relations point at the migration guide and the replacement.- Announce before you set a sunset date, with a migration guide that exists already.
- Give a window proportionate to your consumers - internal teams need weeks, third parties need quarters, paying enterprise customers need longer than you want to give.
- Emit the headers on every response from the deprecated version, not just on a status page.
- Instrument it. You should be able to name which API keys are still on the old version, and contact them.
- Consider a brownout: return
410for short scheduled windows before the sunset, so consumers who ignored the headers discover it under controlled conditions. - After sunset, return
410 Gonewith a problem document explaining where to go - not a404, which looks like a bug.
How to avoid needing v2 at all
- Expand and contract. Add the new field alongside the old one, populate both, migrate consumers, then remove the old one in the next major version. Two deploys, no breakage.
- Tolerant reader. Tell consumers, in writing, to ignore unknown fields and unknown enum values. Then adding fields is genuinely safe rather than theoretically safe.
- Sparse fieldsets.
?fields=id,status,totallets clients ask for what they need, which reduces how much of your response shape they actually depend on. - Never expose your database schema directly. A response DTO you control is what gives you room to refactor storage without touching the contract.
- Reserve
nullsemantics early. Decide whether an absent field and a null field mean different things, and document it - changing your mind later is breaking.
Contracts
A contract that only exists in prose is not a contract. It has to be machine-readable, and something has to fail when the implementation drifts from it.
OpenAPI, and where it should live
openapi: 3.2.0
info:
title: Orders API
version: 1.4.0
paths:
/orders:
get:
operationId: listOrders
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
- name: cursor
in: query
schema: { type: string }
responses:
'200':
description: A page of orders
content:
application/json:
schema: { $ref: '#/components/schemas/OrderPage' }
'422':
$ref: '#/components/responses/Problem'
components:
responses:
Problem:
description: Validation failed
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }additionalOperations, with no breaking changes from 3.1.Spec-first or code-first is a genuine trade rather than a settled question. Writing the spec first forces design discussion before implementation and makes the contract the artifact everyone reviews. Generating it from typed code - FastAPI, Hono with zod-openapi, tRPC-adjacent tooling - guarantees the spec matches reality, which is the failure mode that actually hurts.
Pick based on which failure you fear more: a spec nobody implements, or an implementation nobody designed. If you generate, the schemas that validate incoming requests must be the same ones that produce the spec, or you have reintroduced the drift you were avoiding.
Enforcement in CI
# Lint the spec against a style guide
npx @stoplight/spectral-cli lint openapi.yaml
# Fail the build on a breaking change against the previous version
npx oasdiff breaking main-openapi.yaml openapi.yaml --fail-on ERR
# Validate that live responses actually match the spec
npx dredd openapi.yaml http://localhost:3000
# Consumer-driven contract tests
npx pact-broker can-i-deploy --pacticipant orders-api --version $GIT_SHAoasdiff breaking is the highest-value one here. It turns "we accidentally removed a field" from a production incident into a failed pull request.rules:
operation-operationId: error
operation-4xx-response: error
operation-tag-defined: error
no-$ref-siblings: error
paths-kebab-case: error
# Every response must document a problem+json error shape
problem-details-on-errors:
given: $.paths[*][*].responses[?(@property.match(/4\d\d|5\d\d/))]
then:
field: content.application/problem+json
function: truthyContract testing beats integration testing
Consumer-driven contract testing - Pact and its equivalents - has each consumer declare what it needs, and the provider verifies it can satisfy every declared expectation. This catches the case an integration test cannot: a change that is fine for the consumers you tested and breaks one you forgot about.
The operational surface
Rate limits clients can respect
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 42
Retry-After: 42
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/rate-limit-exceeded",
"title": "Rate limit exceeded",
"status": 429,
"detail": "You have used your 1000 requests for this window. Retry in 42 seconds."
}Conditional requests
# First request
GET /orders/874 HTTP/1.1
HTTP/1.1 200 OK
ETag: "a1b2c3d4"
# Later - save the bandwidth
GET /orders/874 HTTP/1.1
If-None-Match: "a1b2c3d4"
HTTP/1.1 304 Not Modified
# And for writes - optimistic concurrency, no lost updates
PATCH /orders/874 HTTP/1.1
If-Match: "a1b2c3d4"
HTTP/1.1 412 Precondition FailedIf-Match on writes is the cheapest fix for the lost-update problem: two clients editing the same record, the second silently overwriting the first.Filtering, sorting and partial responses
GET /orders?status=paid&placed_after=2026-08-01&sort=-placed_at&fields=id,total_cents
# Conventions worth settling once:
# sort=-field leading minus for descending
# status=a,b comma for an OR within one field
# repeated params for AND across values
# fields=a,b sparse fieldsetsLong-running work
POST /exports HTTP/1.1
HTTP/1.1 202 Accepted
Location: /exports/exp_91
Retry-After: 5
{ "id": "exp_91", "status": "pending" }
# The client polls
GET /exports/exp_91 HTTP/1.1
HTTP/1.1 200 OK
{ "id": "exp_91", "status": "completed",
"download_url": "https://...", "expires_at": "2026-08-15T09:00:00Z" }Mistakes that keep recurring
| Mistake | Why it hurts |
|---|---|
| Returning a bare array at the top level | Nowhere to add pagination or metadata later without breaking clients |
Errors inside a 200 response | Every retry library, cache and monitor believes the call succeeded |
| No pagination on a collection | Adding it later is breaking; not adding it is a future outage |
| Offset pagination on live data | Duplicated and skipped rows in exports, silently |
| Exposing database columns directly | Your schema becomes the public contract and you can never refactor |
| Sequential integer IDs in public URLs | Enumerable, and leaks your volume to competitors |
| Inconsistent field naming across endpoints | user_id here, userId there - a permanent tax on every consumer |
| Timestamps without a timezone | Ambiguous. Always RFC 3339 with an offset |
| Booleans where an enum belongs | is_active becomes status the moment a third state appears |
| No correlation ID in errors | Every support conversation starts with an investigation |
| Documentation generated from code but never reviewed | Technically accurate, practically useless - no examples, no explanation of when to use what |
| Breaking changes shipped as "minor" | The fastest way to lose the trust of the teams building on you |
Verdict
The four decisions in the title are really one decision made four times: how much room are you leaving yourself to change your mind? An envelope instead of a bare array. A cursor instead of an offset. A type URI instead of a message string. A generated spec instead of a document. Each one costs almost nothing up front and buys you a change you can make later without a migration.
The versioning strategy matters less than most teams think, because the goal is to need it rarely. Additive change, tolerant readers and response types you control will carry an API for years inside /v1. When you do need /v2, the Deprecation and Sunset headers exist so that consumers find out from your API rather than from a failure.
Every field you return is a promise. The question worth asking before shipping any endpoint is not whether it works, but which parts of it you are prepared to keep forever.
If you do three things from this: put oasdiff breaking in CI so the build fails before your consumers do, adopt RFC 9457 problem details so errors are machine-readable from day one, and paginate every collection with a cursor and an envelope. The rest is convention, and convention is only worth having if it is enforced.
Sources
- RFC 9110 - HTTP semantics, the definitive reference for methods and status codes
- RFC 9457 - Problem Details for HTTP APIs, which obsoletes RFC 7807
- RFC 9745 - the
Deprecationheader field - RFC 8594 - the
Sunsetheader field - OpenAPI Specification 3.2.0 - the current release
- Spectral and oasdiff - linting and breaking-change detection
- Pact - consumer-driven contract testing



