Designing and Documenting APIs
Building APIs other people want to use: consistent naming, useful error responses with Problem Details, OpenAPI descriptions, versioning and deprecation, and good documentation.
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
limitandcursor, all of them do. If one returnscustomersas 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
GETnever changes anything; aDELETEon something already gone does not crash; a field calledstartedis 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:
/outagesand/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) andsnake_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: 90rather thanduration: 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 toabout: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@. ALinkwithrel="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
typewith what causes it and what the client should do. -
Limits: rate limits and what happens when you hit
them (
429andRetry-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.