EKSING
API development

API design that holds up: contracts, versioning and backward compatibility

Contract first with OpenAPI, versioning and its trade-offs, the difference between backward-compatible and breaking changes, errors as Problem Details (RFC 9457), pagination and idempotency.

Claus Eksing
October 15, 2026 · 6 min read
Blue network cables connected to a server rack

An API is a promise. When an app, a partner or another department builds on it, every detail becomes part of the promise: field names, data types, status codes, the order of results, even the errors you forgot to document. APIs that hold up are not the ones that never change, but the ones that can change without breaking what already depends on them.

The examples use the fictitious company Space Travel and its booking API, but the principles apply to most HTTP APIs.

The contract first

The most effective lever is to write the contract before the code exists. With OpenAPI you describe the endpoints, parameters, request and response formats and errors in one file that both people and tools can read. That gives you three things at once:

  • A conversation before the code. Frontend, backend and partners can discuss and change the contract while it is still cheap.
  • Generation and validation. Clients, types and mock servers can be generated from the contract, and the server can be validated against it in the tests.
  • One source of truth. The documentation is the contract, so the two cannot drift apart.

An excerpt of Space Travel's contract for booking seats on a trip:

openapi.yaml (excerpt)
paths:
  /bookings:
    post:
      operationId: createBooking
      summary: Book seats on a trip
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Unique per booking attempt. A retry with the same key returns the original result.
          schema:
            type: string
            maxLength: 64
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BookingRequest"
      responses:
        "201":
          description: The booking was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Booking"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
components:
  responses:
    Problem:
      description: An error as Problem Details (RFC 9457).
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"

Versioning and its trade-offs

Sooner or later a change comes along that cannot be made backward compatible. There are three common ways to version:

  • In the URL (/v1/bookings). Visible, easy to route and cache, and easy for everyone to understand. The downside is that the whole API changes version at once, including the parts that did not change.
  • In a header or media type (Accept: application/vnd.space-travel.v2+json). The URLs stay stable, and the version can be chosen per resource. On the other hand it is invisible in a browser and easier for clients to forget.
  • Date-based versions, where the client pins the version it was built against and changes are released as new dates. That gives small, frequent steps, but needs more discipline and tooling on the server.

None of them is right in itself. What matters most is choosing one, using it consistently and writing down when a version is retired. HTTP has standardized headers for exactly that: Deprecation (RFC 9745) says that a resource is or will be deprecated, and Sunset (RFC 8594) says when it stops responding.

Backward compatible or breaking?

A useful rule of thumb is that you may add, but not remove or change meaning.

  • Usually safe: a new endpoint, a new optional field in a request, a new field in a response, a new optional query parameter.
  • Breaking: removing or renaming a field, changing a data type, making an optional field required, changing a status code, or giving an existing field a new meaning.

Note the "usually". A new field in a response breaks clients that fail on unknown fields, and a new value in an enum breaks clients that assume the list is exhaustive. So write in the contract that clients must ignore unknown fields and tolerate new enum values, and test your own clients against that.

Response from a deprecated v1 endpoint
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1798761600
Sunset: Fri, 31 Dec 2027 23:59:59 GMT
Link: <https://api.space-travel.example/v2/bookings>; rel="successor-version"

Errors in a fixed format

Errors are part of the contract too, and they deserve the same care as the success responses. RFC 9457, Problem Details for HTTP APIs, describes a common format with the media type application/problem+json and the members type, title, status, detail and instance. You may add your own members that help the client move on. When Space Travel's trip is sold out, the API responds like this:

HTTP 409 · Content-Type: application/problem+json
{
  "type": "https://api.space-travel.example/problems/trip-sold-out",
  "title": "The trip is sold out.",
  "status": 409,
  "detail": "Trip MARS-2031-07 has no free seats left; 2 were requested.",
  "instance": "/v1/bookings/attempts/7f3c9a",
  "tripId": "MARS-2031-07",
  "seatsLeft": 0
}

type is a stable URI that the client can recognize and act on, while title and detail are for people. Clients should never parse the text to work out what went wrong.

Pagination

Lists grow. An endpoint that returns everything only holds up until there is too much. There are two common patterns:

  • Offset and limit (?offset=40&limit=20) is simple and lets the user jump to a specific page. On the other hand, items can be shown twice or skipped when data changes between two requests, and large offsets get slow in the database.
  • Cursor (?cursor=…&limit=20, where the response contains nextCursor) points to where the previous page ended. It is stable while data changes, and fast no matter how far along you are. On the other hand you cannot jump straight to page 37.

Whichever you choose: set an upper bound for limit, and keep the sort order unambiguous, so two items with the same value always come in the same order.

Idempotency

Networks fail. A client that sends a booking and gets no response does not know whether the booking was created. If it tries again, it risks booking twice.

HTTP's GET, PUT and DELETE methods are idempotent: repeating them has the same effect as calling them once. POST is not. A common pattern is an Idempotency-Key header, as in the contract above: the client generates a unique key per attempt, and the server stores the result under that key. If the same key comes again, the original result is returned instead of creating a new booking. The pattern is widespread in payment APIs; the IETF draft describing the header has not become a standard, so the contract itself should describe how the key is used and how long it is remembered.

Keep the promise over time

An API that holds up is rarely the result of a single good design. It is the result of a process: the contract is written and reviewed before the code, every change is assessed as either backward compatible or breaking, breaking changes get a new version and a retirement date, and the contract is tested so the server does not quietly drift away from it. That sounds like a lot, but it is cheaper than discovering that a partner's integration broke because a field changed its name.

In short

  • Write the contract in OpenAPI before the code exists, and let it be the documentation.
  • Pick one versioning strategy, use it consistently, and announce retirement with Deprecation and Sunset.
  • Add freely, but never remove or change meaning without a new version.
  • Return errors as Problem Details (RFC 9457) with a stable type.
  • Cursor pagination with an upper bound holds up while data changes.
  • Make POST safe to retry with an idempotency key, described in the contract.

Does your API need to hold up for years?

We are happy to help with contracts, versioning and a release process that lets the API evolve without breaking the people who build on it. Let us have an informal chat.

Book an informal conversation
  • api
  • rest
  • openapi
  • versionering
Claus Eksing
Senior software architect with 25+ years of experience in system design, API development and AI-assisted development.
Book a conversation

Related posts

See all posts →
A robot hand and a human hand reaching toward each other on a turquoise background
AI & Vibe Coding

AI-assisted development without compromising on quality

What AI coding tools and vibe coding are good at, where both the agent and we humans fail, and the boundaries that give you the speed without paying for it in quality and security: tests first, small changes, human review and clear rules for the agent.

October 23, 2026 · 8 min read