Skip to content
NexiferLabs
All services
Solutions overview
Browse the library
About Nexifer
api-designresthttpbackendarchitecture

REST APIs in practice: versioning, pagination, errors, and contracts that survive change

How to design REST APIs you can still change in three years - breaking-change rules, cursor pagination, RFC 9457 errors, and contracts enforced in CI.

T

team

15 min read
A stylised illustration of a REST API, with a client and server connected by a stream of data. The client is represented as a computer, and the server is represented as a cloud. The stream of data is represented as a series of arrows, with the arrows pointing from the client to the server.

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 changeBreaking
Adding a new optional field to a responseRemoving a field, or renaming one
Adding a new optional request parameterMaking an optional parameter required
Adding a new endpointChanging a field's type or format
Adding a new enum value to a requestAdding an enum value to a response
Adding a new error code within an existing statusChanging which status code a condition returns
Relaxing a validation ruleTightening one
Adding a new optional headerChanging 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.

http
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 route
Plural nouns, lowercase, hyphens between words. Consistency matters more than which convention you pick - but pick one and write it down.

Status codes that mean something

CodeUse it when
200 OKSuccess with a body
201 CreatedA resource was created. Include a Location header
202 AcceptedWork was queued. Return something the client can poll
204 No ContentSuccess with nothing to say - typically DELETE
400 Bad RequestMalformed syntax - unparseable JSON, missing required field
401 UnauthorizedNo credentials, or invalid ones. Actually means unauthenticated
403 ForbiddenAuthenticated, but not allowed
404 Not FoundNo such resource - or a resource this caller may not know exists
409 ConflictState conflict - duplicate, version mismatch, illegal transition
410 GoneIt existed and is permanently removed. Useful for retired endpoints
422 Unprocessable ContentSyntactically valid, semantically wrong - failed business validation
429 Too Many RequestsRate limited. Include Retry-After
The 400 versus 422 line: 400 means "I could not parse this", 422 means "I understood it and it is wrong". Pick one rule and apply it everywhere.

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.

http
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 409 rather 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

json
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_stock is 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 200 body is the single most common way to break clients that trust HTTP.
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 / pageCursor / keyset
Looks like?page=3&per_page=20?limit=20&cursor=eyJpZCI6ODc0fQ
Jump to an arbitrary pageYesNo
Total count availableCheapExpensive or omitted
Performance at depthDegrades linearly - page 5,000 scans 100,000 rowsConstant, whatever the depth
Stable while data changesNo - inserts shift everything, causing duplicates and skipsYes
Good forAdmin tables, small collections, page-number UIsFeeds, 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.

sql
-- 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;
The tiebreaker column is not optional. Sorting by placed_at alone means rows sharing a timestamp can be duplicated or skipped across page boundaries.

A response shape that can grow

http
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
  }
}
Returning an object rather than a bare array is what lets you add pagination, metadata or warnings later without a breaking change. A top-level array has nowhere to put anything.
  • 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.

ApproachLooks likeTrade-off
URL path/v2/ordersObvious, 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 headerX-API-Version: 2Clean URLs, but invisible in logs and browser testing, and easy to forget to send
Media typeAccept: application/vnd.example.v2+jsonThe most technically correct. Also the most awkward for consumers, and poorly supported by tooling
Date-basedX-API-Version: 2026-08-14Stripe's approach. Pins a client to a point in time; excellent for gradual change, and real work to implement
No versioningAdditive change only, foreverViable 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
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/json
Deprecation carries a timestamp as a structured field. Sunset uses an HTTP date. The Link relations point at the migration guide and the replacement.
  1. Announce before you set a sunset date, with a migration guide that exists already.
  2. 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.
  3. Emit the headers on every response from the deprecated version, not just on a status page.
  4. Instrument it. You should be able to name which API keys are still on the old version, and contact them.
  5. Consider a brownout: return 410 for short scheduled windows before the sunset, so consumers who ignored the headers discover it under controlled conditions.
  6. After sunset, return 410 Gone with a problem document explaining where to go - not a 404, 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,total lets 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 null semantics 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.yaml
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' }
OpenAPI 3.2 added hierarchical tags, first-class streaming (SSE, JSON Lines, multipart) and custom methods via 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

bash
# 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_SHA
oasdiff breaking is the highest-value one here. It turns "we accidentally removed a field" from a production incident into a failed pull request.
.spectral.yaml
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: truthy
A linter is how design conventions stop being a document nobody reads and start being a gate. Write the rules once; they apply to every future endpoint automatically.

Contract 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
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."
}
Send the limit headers on successful responses too, not only on rejections. A client that can see it is approaching the limit can slow down; one that only learns on rejection cannot.

Conditional requests

http
# 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 Failed
If-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

http
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 fieldsets
Whatever you choose, apply it everywhere and put it in the linter. Inconsistent filtering conventions across endpoints are the most common complaint consumers have about an API.

Long-running work

http
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" }
Never hold a request open for a minute waiting on work. Return a resource representing the job, and let the client poll or receive a webhook.

Mistakes that keep recurring

MistakeWhy it hurts
Returning a bare array at the top levelNowhere to add pagination or metadata later without breaking clients
Errors inside a 200 responseEvery retry library, cache and monitor believes the call succeeded
No pagination on a collectionAdding it later is breaking; not adding it is a future outage
Offset pagination on live dataDuplicated and skipped rows in exports, silently
Exposing database columns directlyYour schema becomes the public contract and you can never refactor
Sequential integer IDs in public URLsEnumerable, and leaks your volume to competitors
Inconsistent field naming across endpointsuser_id here, userId there - a permanent tax on every consumer
Timestamps without a timezoneAmbiguous. Always RFC 3339 with an offset
Booleans where an enum belongsis_active becomes status the moment a third state appears
No correlation ID in errorsEvery support conversation starts with an investigation
Documentation generated from code but never reviewedTechnically 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.

Our standing rule for API reviews

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

Back to Blog
Share:

Related Posts