Principles: predictable beats clever

API design is deciding, before you write code, what an API looks like from the outside: its resources and operations, names, data formats, errors and how it will change over time. Once other teams depend on it, that outside is an API contract and hard to change. Three principles carry most of the weight:

  • Consistency. The same thing has the same name and shape everywhere. If one endpoint pages with limit and cursor, all of them do. If one returns customers as a number, none returns it as a string.
  • Predictability. A developer who has used two endpoints can guess the third. The REST conventions help: resources are nouns, HTTP methods are the verbs, status codes mean what HTTP says they mean.
  • Least surprise. A GET never changes anything; a DELETE on something already gone does not crash; a field called started is a time, not a boolean. When the API does something unexpected, people write wrong code without noticing.

Many teams write the description first (design-first), review it with the people who will call the API, and only then build it. Changing a YAML file in a review costs minutes; changing a live API costs every client a release.

Naming and formats

Most style guides agree on a handful of rules:

  • Plural nouns for collections, an ID for one item: /outages and /outages/OUT-1042, not /getOutage?id=…. Nest only where the relationship is real and shallow: /outages/OUT-1042/updates.
  • One case style, everywhere. Paths are usually lowercase with hyphens (/planned-maintenance, called kebab case). For JSON fields, camelCase (startedAt) and snake_case (started_at) are both common. Which one matters less than never mixing them.
  • Dates and times in ISO 8601 (more precisely its internet profile, RFC 3339), always with a time zone: "2026-10-01T07:42:00Z". Never "01-10-2026", which half the world reads as January 10.
  • Explicit units and types. durationSeconds: 90 rather than duration: 90; money as a decimal string or integer cents plus a currency code, not a floating-point number.
  • Stable IDs as strings. Even when they look like numbers: a JavaScript client silently loses precision on integers above 253.

Error responses with Problem Details

A bare 400 Bad Request tells the caller something is wrong, not what. Every API used to invent its own error body. RFC 9457, Problem Details for HTTP APIs (August 2023, it obsoletes RFC 7807 from 2016), defines a standard one. Problem Details is a JSON object with the media type application/problem+json and five standard members:

  • type: a URI that identifies the kind of problem, ideally pointing to documentation about it (defaults to about:blank);
  • title: a short, human-readable summary of that kind;
  • status: the HTTP status code, repeated for convenience;
  • detail: what went wrong in this case;
  • instance: a URI for this specific occurrence.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en
{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "The request contains invalid fields",
  "status": 422,
  "detail": "2 fields failed validation.",
  "instance": "/v1/outages/reports/7781",
  "errors": [
    {"pointer": "#/area", "detail": "must not be empty"},
    {"pointer": "#/started", "detail": "must be an ISO 8601 date-time, for example 2026-10-01T07:42:00Z"}
  ]
}

The errors array is an extension member: RFC 9457 lets you add your own fields, and its own examples use this pattern for validation errors, with a JSON Pointer to the field. Clients branch on type (stable) and show detail (human text, which may change).

Describing the API with OpenAPI

OpenAPI is a vendor-neutral format for describing an HTTP API in a YAML or JSON document: its paths and operations, parameters, request and response bodies, status codes and authentication. It began as Swagger and is now maintained by the OpenAPI Initiative. As of October 2026 the current version is 3.2.1 (September 2026), a patch release of 3.2.0 (September 2025); 3.1 (2021), which aligned its schemas fully with JSON Schema 2020-12, is what most tools support today, and many existing descriptions are still 3.0.

This description covers two operations of Kestrel Energy's outage API, including the Problem Details error for an unknown ID:

openapi: 3.1.0
info:
  title: Kestrel Energy Outage API
  version: 1.4.0
  description: Current and recent power outages in the Kestrel Energy grid.
servers:
  - url: https://api.example.com/v1
paths:
  /outages:
    get:
      summary: List current outages
      operationId: listOutages
      parameters:
        - name: area
          in: query
          required: false
          description: Only outages in this municipality.
          schema:
            type: string
            examples: [Zwolle]
      responses:
        "200":
          description: The current outages, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Outage"
  /outages/{id}:
    get:
      summary: Get one outage
      operationId: getOutage
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: "^OUT-[0-9]+$"
            examples: [OUT-1042]
      responses:
        "200":
          description: The outage.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Outage"
        "404":
          description: No outage with this ID.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
components:
  schemas:
    Outage:
      type: object
      required: [id, area, started, customers]
      properties:
        id:
          type: string
          examples: [OUT-1042]
        area:
          type: string
          examples: [Zwolle]
        started:
          type: string
          format: date-time
          examples: ["2026-10-01T07:42:00Z"]
        customers:
          type: integer
          description: Number of connections without power.
          examples: [1250]
    Problem:
      type: object
      properties:
        type:
          type: string
          format: uri-reference
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
          format: uri-reference

Read it from the top: info names the API and the version of this description; servers gives the base URL; each path lists its operations with their parameters and responses; and components holds reusable schemas that the operations point to with $ref. The Outage schema is JSON Schema, so the same rules can validate real requests and responses.

What tools do with it

  • Documentation. Renderers such as Swagger UI or Redoc turn the file into browsable reference pages, often with a "try it" button.
  • Client and server code. Generators produce client libraries (an SDK) in many languages, or server stubs to fill in.
  • Validation. Middleware and API gateways check incoming requests against the schemas and reject bad input before it reaches your code. Linters check the description itself against your style rules (plural nouns, one case style, every operation documented).
  • Mock servers and tests. A mock server can answer from the examples before the real API exists, and contract tests check that the real API still matches the description.
  • Change detection. Comparing two versions of the description finds breaking changes automatically, as the next section shows.

Backward compatibility and breaking changes

Backward compatibility means that a new version of the API still works for every client written against the old one, without changing a line of their code. A breaking change is any change that can make such a client fail: a request that used to succeed is now refused, or a response it used to understand now confuses it.

Usually safe Breaking
Adding a new endpoint or operation Removing or renaming an endpoint
Adding a field to a response Removing or renaming a response field
Adding an optional request parameter Adding a required parameter, or making an optional one required
Accepting more input than before (a longer maximum length) Accepting less (a shorter maximum, a stricter pattern)
Improving the text of detail or title Changing a field's type (1250 to "1250") or format
Better performance Changing a status code, an error type, the default sort order or the meaning of a field

"Usually" matters. Adding a response field breaks clients that reject unknown fields, and adding a value to an enumeration breaks clients that handle every known value in a fixed list. Tell clients up front to ignore unknown fields and to expect new enum values; this is sometimes called the tolerant reader rule. Between your own services the other direction matters too, forward compatibility, together with Hyrum's law and expand-and-contract changes; see API Design Between Services.

This example compares two versions of a tiny API description and sorts the differences into breaking changes and safe additions. Tools that compare OpenAPI documents do the same on a larger scale, often as a check in the CI pipeline.

# Two versions of a tiny API description: per operation, its parameters
# and the fields of its response.
v1 = {
    "GET /outages": {
        "params": {"area": {"type": "string", "required": False}},
        "fields": {"id": "string", "area": "string", "customers": "integer"},
    },
    "GET /outages/{id}": {
        "params": {"id": {"type": "string", "required": True}},
        "fields": {"id": "string", "area": "string", "customers": "integer",
                   "started": "string"},
    },
}

v2 = {
    "GET /outages": {
        "params": {"area": {"type": "string", "required": False},
                   "status": {"type": "string", "required": True}},
        "fields": {"id": "string", "area": "string", "customers": "string",
                   "cause": "string"},
    },
    "GET /outages/{id}": {
        "params": {"id": {"type": "string", "required": True},
                   "lang": {"type": "string", "required": False}},
        "fields": {"id": "string", "area": "string", "customers": "integer"},
    },
    "GET /areas": {"params": {}, "fields": {"name": "string"}},
}


def compare(old, new):
    breaking, safe = [], []
    for op in sorted(old.keys() | new.keys()):
        if op not in new:
            breaking.append(f"{op}: operation removed")
            continue
        if op not in old:
            safe.append(f"{op}: new operation")
            continue
        before, after = old[op], new[op]
        for name, spec in sorted(after["params"].items()):
            if name not in before["params"]:
                if spec["required"]:
                    breaking.append(f"{op}: new required parameter '{name}'")
                else:
                    safe.append(f"{op}: new optional parameter '{name}'")
        for name, kind in sorted(before["fields"].items()):
            if name not in after["fields"]:
                breaking.append(f"{op}: field '{name}' removed")
            elif after["fields"][name] != kind:
                breaking.append(f"{op}: field '{name}' changed from "
                                f"{kind} to {after['fields'][name]}")
        for name in sorted(after["fields"].keys() - before["fields"].keys()):
            safe.append(f"{op}: new field '{name}'")
    return breaking, safe


breaking, safe = compare(v1, v2)
print("Breaking changes:")
for line in breaking:
    print("  -", line)
print("Safe additions:")
for line in safe:
    print("  +", line)
print("Verdict:", "new major version needed" if breaking else "compatible")
Breaking changes:
  - GET /outages: new required parameter 'status'
  - GET /outages: field 'customers' changed from integer to string
  - GET /outages/{id}: field 'started' removed
Safe additions:
  + GET /areas: new operation
  + GET /outages: new field 'cause'
  + GET /outages/{id}: new optional parameter 'lang'
Verdict: new major version needed

Versioning and deprecation

When a breaking change is unavoidable, the old behavior must keep working for a while next to the new one. That is what API versioning is for. The most common style is a major version in the path (/v1/outages, /v2/outages); others use a header, a media type or a date (as Stripe does). Whatever the style, only breaking changes need a new major version; additions go into the current one. Many teams number the description itself with semantic versioning (the 1.4.0 in info.version above).

Deprecation is the announcement that an operation, field or version still works but will be removed, and that clients should move away from it. Two HTTP response headers make the announcement machine-readable:

  • Deprecation (RFC 9745, March 2025) says that the resource is or will be deprecated, as a Unix timestamp after an @. A Link with rel="deprecation" points to the explanation.
  • Sunset (RFC 8594, May 2019) gives the date after which the resource is expected to stop responding. It is an Informational RFC, and the date is a promise, not a guarantee.
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1793491200
Sunset: Sat, 01 May 2027 00:00:00 GMT
Link: <https://developer.example.com/changelog#v1-retirement>; rel="deprecation"; type="text/html"

Here v1 is deprecated as of 1 November 2026 (the timestamp; a future date announces it in advance) and is planned to stop on 1 May 2027. Headers alone are not enough: most client code never looks at them. Announce the change in the changelog and by email, log which clients still call the old version (an API gateway can show this per key), and contact them before the date.

Documentation that helps

Generated reference pages list every field, but people learn from examples. Good API documentation has, in addition to the reference:

  • A quick start that gets a first successful call in five minutes: how to get credentials, one complete request (often as a curl command) and its full response.
  • Authentication explained: which method, where the token comes from, how long it lives and what the scopes are.
  • Realistic examples for every operation, request and response, including the error responses.
  • All errors listed: each Problem Details type with what causes it and what the client should do.
  • Limits: rate limits and what happens when you hit them (429 and Retry-After; see Web and API Security), page sizes, maximum request size.
  • A changelog with dates, marking every deprecation and breaking change, and a way to subscribe to it.

Keep the documentation in the same repository as the code and the OpenAPI file, and let the pipeline fail when they disagree. Docs that are updated by a separate team, later, are wrong by default.