0041 - API standards for contracts and their evolution
| ID: | ADR-0041 |
|---|---|
| Status: | PROPOSED |
| Published: | 2026-10-05 |
Notation
This ADR uses RFC 2119 keywords (MUST, MUST NOT,
SHOULD, SHOULD NOT, MAY) deliberately.
Context and problem statement
ADR-0036 requires service-to-service APIs to conform to a documented set of standards, and ADR-0039 builds those standards on IETF RFCs. This ADR sets how an API's contract is described and addressed, and how it changes over time without breaking its callers: OpenAPI descriptions, paths, versioning, unrecognized input, and deprecation.
Independently deployed services are rolled out, and sometimes rolled back, on their own schedules, so a contract has to tolerate callers and services at different versions.
Considered options
Versioning
- A major version in the path (
/api/v1/...), with breaking changes avoided. - No versioning: additive changes only, as in Bitwarden's public API today.
- Version in a header or in the media type.
- A dated
api-versionquery parameter, as in the Azure guidelines.
Request scope
- The organization and user travel as context, never in the path.
- The organization in the path, as in
/organizations/{id}/users.
Unrecognized input
- Ignore unrecognized fields, parameters, and headers, and reject only parameters that shape the result.
- Reject anything unrecognized with
400, as JSON:API requires.
Deprecation
- Mark it in OpenAPI and announce it in response headers
(RFC 9745
Deprecation, RFC 8594Sunset, and RFC 8288Link). - Announce it in documentation and changelogs only.
Decision outcome
Chosen options: a major version in the path, scope as context, ignoring unrecognized input, and deprecation in OpenAPI and response headers.
OpenAPI
APIs MUST be described in OpenAPI.
- Descriptions
MUSTbe written for the consumer and free of implementation details. - APIs
SHOULDprovide realistic request and response examples, andSHOULDdocument which attributes can be filtered and sorted. - Fields that are always present
MUSTbe markedrequired, fields that may be nullMUSTbe markednullable, and fields set by the serverMUSTbe markedreadOnly. An APIMUSTignore a read-only field in a request. - Every operation
MUSThave an explicit, stableoperationId. Version 1 operationsSHOULD NOTinclude the version, and later versionsMUST(for examplegetGroup, thengetGroupV2), so that generated clients do not collide on one method name or depend on names a generator invents.
Paths
- Paths
MUSTbe lowercase kebab-case. - The first element
MUSTbe a namespace,apiby default. The nextMUSTbe the version, starting atv1. The nextMUSTname the resource, andSHOULDbe its plural (for exampleusers). Where the API targets one resource, the nextSHOULDbe its ID. - Paths
SHOULDbe traversable: if/api/v1/users/123/addresses/456names an address, its parent paths name all of that user's addresses, the user, and all users. This shows how paths fit together. It does not require every parent path to exist.
/api/v1/users
/api/v1/users/123
/api/v1/users/123/addresses
Every request acts on a subject and runs inside a scope:
- The subject is what the request acts on, and it
MUSTbe named in the path. - The scope is what the request runs inside, such as the current organization and user. It
MUST NOTbe named in the path, and travels as context instead. CallersSHOULDsend the current organization and user on every request where each exists. currentSHOULDbe used where the subject is whatever the scope names, as in/api/v1/users/current.
An identifier in a path is something a caller can try other values of, and the API has to prove on every request that the caller may use it. A scope taken from context is established once, where the caller authenticates, and one path then serves a resource that can belong to more than one kind of owner. Which parts of the scope an operation requires is declared by the operation, and is the subject of the forthcoming authentication and authorization standard.
A caller working across several organizations can make one call per organization, use a filter such
as filter[organization]=123,456, or use an advanced query (see ADR-0043). None of these puts the
organization in the path. An operation can also be declared so that less scope means more, for
example returning users across every organization the caller belongs to when no organization is in
context.
Breaking changes and versioning
APIs SHOULD NOT make breaking changes. A breaking change is one that causes a request that is
valid today to be rejected tomorrow, or a response a caller can process today to become
unprocessable. In practice:
- adding a required field, or making an optional field required,
- changing a field's type,
- adding constraints to a field, or
- removing a field from a response.
The keyword is SHOULD NOT rather than MUST NOT because these are service-to-service APIs and we
own every caller:
- A team
MAYmake a breaking change in place when it can account for every caller, either because the change is coordinated with them or because the rejection surfaces where the caller or user can act on it. - Bugs
SHOULDbe fixed in place, even when the fix is technically breaking. - Otherwise, a breaking change
MUSTbe made in a new version of the API, and the old version deprecated.
Adding a value to a constrained field is not breaking by this definition, but a generated client that maps the field to a closed enumeration will fail on a value it has never seen. Consider whether callers tolerate unknown values before adding one.
Versioning costs a path segment while an API stays at v1. In exchange, a contract that needs to
change shape has somewhere to go, instead of making every new field optional so that nothing ever
breaks. The version is in the path because it is then visible in logs, traces, routing rules, and
curl commands, needs no content negotiation, and lets two versions run side by side behind one
host.
Unrecognized fields, query parameters, and headers
A service rolled back to fix a defect can receive fields from callers that have already upgraded. So
that callers do not have to be rolled back too, APIs MUST ignore unrecognized fields, query
parameters, and headers.
The exception is a parameter that shapes the result, because ignoring it returns something other
than what was asked for. APIs MUST reject with 422:
- a
filter[{name}],sort, orfieldsparameter naming a field the API does not support, - paging parameters when the API does not page, or for a paging style it does not implement, and
- on a bulk operation, an
atomicvalue it does not support.
Deprecation
- A deprecated API
MUSTbe markeddeprecated: truein OpenAPI, and its descriptionMUSTname the replacement. - Every response from a deprecated API
MUSTinclude theDeprecationandSunsetheaders, andSHOULDinclude aLinkheader naming the replacement. Deprecationis the time the API was deprecated, as@followed by seconds since the Unix epoch.Sunsetis the date it stops working. ItMUSTbe a real date, andMUST NOTpass without either removal or a published extension.
Deprecation: @1688169599
Sunset: Sun, 30 Jun 2024 23:59:59 GMT
Link: <https://api.example.com/api/v2/users>; rel="successor-version"
Positive consequences
- Contracts are described precisely enough to generate clients that stay stable across versions.
- A rolled-back service keeps working with callers that have already moved forward.
- An unavoidable breaking change has a defined path: a new version, with the old one deprecated on a stated schedule.
- Paths cannot be used to reach another organization's resources by changing an identifier.
Negative consequences
- Service-to-service APIs carry a version where Bitwarden's public API does not.
- Ignoring unrecognized fields means a misspelled optional field is silently dropped.
- Teams have to judge when a breaking change can be made in place, and that judgement can be wrong.