From 660711400696d0b5ed0b4861b28e1f34846abad4 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Tue, 15 Sep 2026 07:49:12 -0400 Subject: [PATCH 01/23] Add ADR 0036, internal API standards --- .../adr/0036-internal-api-standards.md | 181 +++ .../server/internal-api-standards.md | 1045 +++++++++++++++++ 2 files changed, 1226 insertions(+) create mode 100644 docs/architecture/adr/0036-internal-api-standards.md create mode 100644 docs/architecture/server/internal-api-standards.md diff --git a/docs/architecture/adr/0036-internal-api-standards.md b/docs/architecture/adr/0036-internal-api-standards.md new file mode 100644 index 000000000..be8e0a6be --- /dev/null +++ b/docs/architecture/adr/0036-internal-api-standards.md @@ -0,0 +1,181 @@ +--- +adr: "0036" +status: Proposed +date: 2026-09-22 +tags: [server, server-sdk] +--- + +# 0036 - Internal API standards + + + +{/* cspell:ignore Appibase Catalio */} + +## Notation + +This ADR uses [RFC 2119](https://www.rfc-editor.org/info/rfc2119/) keywords (`MUST`, `MUST NOT`, +`SHOULD`, `SHOULD NOT`, `MAY`) deliberately. Anything marked `MUST` or `MUST NOT` is not negotiable +at team level; a team that needs an exception brings the case to the architecture group. + +**Internal API** means a service-to-service API: one Bitwarden service calling another. Bitwarden's +existing public API is out of scope and is not changing. + +## Context and problem statement + +Service-to-service APIs are new. As server functionality is decomposed into independently deployable +services, a service calling another service needs a wire contract, and no documented standard +exists. + +One important distinction to keep in mind as we consider various options is that the APIs we are +talking about are _internal_ APIs. We control both the service that declares them and every client +that consumes them. While we _do_ still want to adhere to the principle of "no breaking changes", we +also do not want that to result in an ever-growing number of optional fields that ultimately make +these contracts less clear. + +In the same way we [refactor our code mercilessly](https://wiki.c2.com/?RefactorMercilessly), we +want the liberty to be able to do the same with our APIs. Especially when they are still under +active development and rapidly evolving. + +Said differently, we do not want the rules that restrict how public-facing APIs may evolve to +restrict how, or the pace at which, we evolve internal-only APIs. + +## Considered options + +- **Adopt the existing public API conventions:** apply the same shape and the same no-versioning + posture to internal APIs. +- **Keep the existing JSON shape and add versioning:** retain today's conventions, introduce path + versioning. +- **Adopt [JSON:API](https://jsonapi.org/) strictly:** implement the specification in full, + including its media type and `relationships` objects, and add versioning. +- **Adopt [JSON:API](https://jsonapi.org/) selectively:** implement most of the specification but + don't worry about 100% compliance, and add versioning. + +### Adopt the existing public API conventions + +**Pros** + +- One shape across every Bitwarden API, public and internal. +- Nothing new for engineers to learn, and no migration for code already written this way. + +**Cons** + +- Carries over the no-versioning posture, which forces additive-only evolution on APIs whose callers + we control. +- Its envelope is undocumented and answers only pagination. `object` and `data` exist only as + properties on a response model class, so anything else the response needs to carry has nowhere + defined to go. +- The convention is not written down. A standard that exists only as precedent cannot be cited in + review, generated from, or enforced. + +### Keep the existing JSON shape and add versioning + +**Pros** + +- Solves the versioning problem. +- Smallest change from current practice. + +**Cons** + +- Leaves us maintaining a proprietary standard. The questions an API standard answers (document + shape, metadata, errors, pagination, filtering, sparse responses, and so on) are not + Bitwarden-specific. Where a public specification already proposes answers to these common + questions, our time and energy is better spent adopting it. +- The remaining gaps have to be filled by us, individually, as each one is discovered. + +### Adopt JSON:API strictly + +**Pros** + +- It is a public specification, not house style. Registered as a media type in 2013, now at v1.1, + revised in the open, with implementations across major languages. +- It answers almost every question an API standard faces, not just the obvious ones: document shape, + resource identity, pagination, filtering, sorting, sparse responses, compound documents, metadata, + and errors. +- Responses standardize where to find the resource `type`, `id`, and `attributes`, which enables + generic tooling over the entire API surface. +- Error responses are richly described to facilitate both human-readable details and a + [JSON Pointer](https://www.rfc-editor.org/info/rfc6901/) that identifies the exact member of the + request that failed (which, again, enables generic tooling). + +**Cons** + +- In code, model objects are typically "flat"; on the JSON:API wire, they are nested inside `data` + and `attributes` envelopes. A good framework will mask these envelopes from the actual classes + developers work with, but a direct projection will result in request and response models that feel + awkward. +- Parts of the specification are a real implementation burden for little return (e.g. + `relationships` objects, with their resource linkage and related-resource links). +- Every API must advertise that it accepts and returns the `application/vnd.api+json` media type, + which inevitably creates problems for clients that expect to send and receive `application/json`. + Advertising `application/json` while still accepting `application/vnd.api+json` is only available + to us because we are not claiming full conformance. + +### Adopt JSON:API selectively + +**Pros** + +- Almost all the pros of [Adopt JSON:API strictly](#adopt-jsonapi-strictly) with just one con. + +**Cons** + +- Our APIs will walk like and talk like JSON:API but are not _quite_ JSON:API, which could be + surprising to a human. It is hard to imagine any negative impact to the machine that consumes + them. + +## Decision outcome + +Chosen option: **Adopt JSON:API selectively with versioning**. + +- We believe an established standard, with thoughtful answers to every API question, will be more + robust than any standard we might invent ourselves. It is widely adopted among some of the largest + SaaS vendors in the industry including [ART19](https://marketplace.apilayer.com/art19_content-api) + (an Amazon company) and [Datadog](https://docs.datadoghq.com/api/latest). +- We feel strongly that internal APIs should be formally versioned. Without formal versioning, every + change must be additive, which means the shape can never change and every new field is optional. + Contracts constrained like that get weaker over time, and what we _want_ to express eventually + cannot be expressed, because we have committed ourselves to "additive changes only". This rules + out adopting the existing public API conventions, which are expressly unversioned. +- We adopt the standard selectively to get most of the benefits of JSON:API without the burden of + full conformance. + +The specific standards, including where we deviate from JSON:API, are published here: +[Internal API standards](../server/internal-api-standards.md). That page is the living reference: +its rules evolve by pull request without superseding this decision, and this ADR is superseded only +if the model itself changes. + +### Positive consequences + +- One document shape across every internal API, so a consumer calling five services learns one error + format, one pagination scheme, and one filter grammar. +- Versioning gives a contract a way to change shape. A breaking change becomes a new version with a + migration, instead of more optional parameters. +- The envelope gives pagination state and other response metadata somewhere to live that is not + mixed into the resource. +- Decisions we would otherwise have to make are already made, in public, by a specification that has + been maturing since 2013. + +### Negative consequences + +- Internal APIs "look different" from public APIs. +- Additional work is required to ensure the "envelopes" are largely transparent to developers when + working with request and response models in code. +- Partial conformance invites any deviation from the specification to be argued as allowed. We will + mitigate this by documenting the specific exceptions; we will comply with all other aspects of the + specification. + +### Plan + +- Build out the framework that makes the JSON:API "envelope tax" disappear from daily development. +- Publish a standard for authentication, authorization, and how the current organization travels + with a request. +- Publish a standard for jobs, the resource a `202 Accepted` returns. + +## References + +- [Appibase Docs](https://appibase.com/docs) +- [ART19 Content API](https://marketplace.apilayer.com/art19_content-api) +- [Bitwarden Public API](https://bitwarden.com/help/public-api) +- [JSON:API — Active Ants ShopAPI v3](https://developer.activeants.com/docs/shopapi/v3/general-concepts/jsonapi) +- [JSON:API Overview — Catalio](https://catalio.ai/docs/json-api-overview) +- [JSON:API specification](https://jsonapi.org/) +- [Public API](https://contributing.bitwarden.com/getting-started/server/public-api) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md new file mode 100644 index 000000000..3403cd52b --- /dev/null +++ b/docs/architecture/server/internal-api-standards.md @@ -0,0 +1,1045 @@ +--- +sidebar_position: 3 +--- + +# Internal API standards + +{/* cspell:ignore reate reates eletes elete pdate pdates fieldsets */} + +**Audience:** Bitwarden engineers and AI agents building or consuming a service-to-service API. + +**Scope.** Internal, service-to-service APIs. Bitwarden's existing public API is out of scope and is +not changing; nothing here applies to it. + +This page is the living standard, adopted in [ADR-0036](../adr/0036-internal-api-standards.md). Its +rules evolve by pull request without superseding that decision. + +[RFC 2119](https://www.rfc-editor.org/info/rfc2119/) keywords (`MUST`, `MUST NOT`, `SHOULD`, +`SHOULD NOT`, `MAY`) are used deliberately. A `MUST` or `MUST NOT` is not negotiable at team level; +a team that needs an exception brings the case to the architecture group. + +## General + +In general, RESTful APIs are resource-oriented and do one of 4 things: + +- **C**reate a resource. +- **R**ead a resource. +- **U**pdate a resource. +- **D**elete a resource. + +This is the familiar CRUD paradigm and, while there will always be exceptions, developers `SHOULD` +strive to think in these terms for every API created. Both for simplicity and consistency. + +There is, however, a 5th type of API that doesn't cleanly fit the CRUD paradigm: + +- **Act** upon a resource. + +Standards for each type of API are documented below. + +### JSON:API + +Internal Bitwarden APIs are based on top of [JSON:API](https://jsonapi.org/) unless otherwise noted +in this document. Where our standards are silent, JSON:API standards are assumed. + +### Well-defined APIs + +A well-defined API spells out exactly how it should be called and what the caller can expect in +return - for both the happy path and the not-so-happy path. Developers `SHOULD` strive to think in +terms of resources and be on guard against **API proliferation** that can result from over-tailoring +APIs to the unique needs of this caller or that. + +In general, it is better to have one API that can be called two different ways (e.g. query +parameters) than two APIs that can only be called one way. + +## Authentication and authorization + +How a caller proves its identity, how a service authorizes an operation, and how the current +organization travels with a request are the subject of a forthcoming standard. + +## OpenAPI + +APIs `MUST` be documented in [OpenAPI](https://www.openapis.org/) format. + +- APIs `MUST` provide a description written with the API consumer as the audience in mind, free of + implementation details. +- APIs `SHOULD` provide realistic example JSON for both the request and response. +- APIs `SHOULD` document which attributes are filterable and which are sortable. +- Fields that must always be present `MUST` be marked as `required`. +- Fields whose value may be null `MUST` be marked as `nullable`. +- Fields whose value is set by the server `MUST` be marked as `readOnly`. If a request includes a + read-only field, the API `MUST` ignore it. + +### Operation identifiers + +Every operation `MUST` carry an explicit, stable `operationId`, and it `MUST` include the version +(e.g. `getGroupV1`, `listGroupsV1`, `replaceGroupV1`, `getPolicyV2`). These IDs are necessary for +stable, generated clients: + +1. Without the version, `/api/v1/groups/{id}` and `/api/v2/groups/{id}` collide into one method + name. +1. Without an _explicit_ identifier, generators invent one from the route which can be brittle. + +## API paths + +API paths `MUST` use **lowercase "kebab-case"** and conform as follows: + +1. The first element of the API path `MUST` be a namespace. By default, the namespace `SHOULD` be + `api`. +1. The second element of the API path `MUST` be the version number starting with `v1`. +1. The third element of the API path `MUST` specify the resource or resources it targets. For + resource-oriented APIs, this element `SHOULD` be the "resource plural" (e.g. `users`). +1. For resource-oriented APIs, the fourth element of the API path `SHOULD` be the ID of the resource + it targets. + +> **Why versioning in the path?** It is visible in logs, traces, routing rules and curl commands; it +> needs no content negotiation to read; and it lets two versions coexist behind one host. Header and +> media-type versioning are both defensible but harder to operate. + +**Examples** + +``` +/api/v1/users +/api/v1/users/123 +/api/v1/users/123/addresses +``` + +Paths `SHOULD` be "hackable". If `GET /api/v1/users/123/addresses/456` returns the details about +address 456 of user 123, then every parent path `SHOULD` resolve: + +- `GET /api/v1/users/123/addresses` should return all addresses of user 123. +- `GET /api/v1/users/123` should return details about user 123. +- `GET /api/v1/users` should return all users. + +Organization IDs `SHOULD NOT` appear in the path because "the current organization" is part of the +[context](#authentication-and-authorization) of almost every request and, thus, need not be +duplicated in the URL path. + +## Breaking changes and versioning + +APIs `MUST NOT` make **breaking changes**. A breaking change is defined as follows: + +1. Removing (or relocating) a field. +1. Changing the datatype of a field. +1. Making an optional field required. +1. Adding additional constraints to a field. + +The lone exception to this rule is when the constraints that need to be added are fixing a bug. In +this case, even though it is technically a "breaking change", API versioning is not required: the +constraint may be added to the existing version of the API. + +If changes need to be made that _would_ be breaking changes, a new version of the API must be +created and the old one [deprecated](#deprecation). + +> **Adding a value to a constrained field deserves a second look.** It is additive, so it is not a +> breaking change by the definition above, and a caller that treats the field as an open string is +> unaffected. But a generated client that deserializes the field into a closed enumeration will fail +> on a value it has never seen — and it will fail at the client, on a change that looked safe from +> the service. Consider whether the callers of that field are tolerant of unknown values before +> adding one. + +## Unrecognized fields, query parameters, and headers + +Sometimes a new, optional field, query parameter, or header is added to an API. Consumers upgrade to +new clients and start passing the new fields/parameters/headers and everything is great until a +security issue is discovered. The service is rolled back to the previous version but consumers are +still using newer versions of the client. + +To keep from having to _also_ revert all of the consumers, APIs `MUST` ignore unrecognized fields, +parameters, and headers. + +The exception is a parameter that shapes the result. Ignoring an unrecognized field means doing less +than the caller asked; ignoring one of these means returning something other than what was asked +for. APIs `MUST` reject the request with `422` when given: + +- a `filter[{name}]`, `sort`, or `fields` parameter naming a field they do not support, or +- paging parameters when the API does not support paging, or for a paging style it does not + implement. + +## JSON + +- Dates `MUST` be formatted in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format (e.g. + `2026-01-01T00:00:00Z`) and `MUST` include a time zone indicator and `SHOULD` standardize on UTC. +- Field values that are constrained to a fixed set of values should enumerate the valid values in + all caps (e.g. `RED`, `GREEN`, `BLUE`) both in the OpenAPI spec and in example JSON to help + distinguish these fields from free-text string fields. At runtime, however, APIs `MUST` ignore + case when validating these values. +- APIs `SHOULD` strip leading and trailing whitespace from all strings before processing them. +- APIs `MUST` treat an empty string the same as if the field is not present. +- APIs `MUST` treat a field that isn't present the same as `null`. +- APIs `SHOULD NOT` include `null` fields in responses. + +## Naming conventions + +- Field names `MUST` be in camel case (e.g. `firstName`, `lastName`). +- Fields holding a date or date/time `MUST` end in `At` (e.g. `createdAt`, `expiresAt`). +- Boolean fields `MUST NOT` be prefixed with `is` (e.g. `active`, not `isActive`). +- Fields whose value is the identifier of another resource `SHOULD NOT` be suffixed with `Id`. A + string-valued `assignedTo` is self-evidently the identifier of the user it is assigned to; + `assignedToId` adds nothing. + +## Content negotiation + +- Services `MUST` respect the `Accept` media type requested by the caller. If the caller asks for + XML and the server cannot return XML, the service `MUST` return `406 Not Acceptable`. +- Because our APIs are largely based on JSON:API, services `MUST` accept a request whose + `Content-Type` is `application/vnd.api+json`, and `MUST` honor an `Accept` of + `application/vnd.api+json`, even though the API itself only advertises `application/json`. +- Services `MUST` return `415 Unsupported Media Type` if the server can't process the specified + `Content-Type`. + +## Standard responses + +APIs `SHOULD NOT` document standard responses because these responses apply to every API and +documenting them over and over again just creates noise. The following responses are possible for +every API: + +| Response | Description | +| -------- | ------------------------------------------------------------------------------------------------------- | +| `400` | A malformed request (i.e. invalid JSON). | +| `401` | The caller isn't [recognized](#authentication-and-authorization) (i.e. not authenticated). | +| `403` | The caller isn't [authorized](#authentication-and-authorization) to invoke the API (i.e. forbidden). | +| `404` | The resource specified via the URL path does not exist (or the caller isn't allowed to know it exists). | +| `405` | The HTTP method is not allowed. | +| `406` | The API doesn't return the specified media type. | +| `409` | The attempted update conflicts with some other update (i.e. was changed since the caller last read it). | +| `415` | The API doesn't accept the specified media type. | +| `422` | An error the client can fix. | +| `429` | The caller has made too many requests. | +| `500` | An unexpected error the client cannot fix. | +| `501` | The API has just been stubbed out and has not been implemented, yet. | +| `503` | The request cannot be completed because a dependency is unavailable or timed out. | + +### 400 vs. 422 + +- Return `400` for requests that are malformed and cannot even be parsed (e.g. invalid JSON). +- Return `422` for invalid requests. See [Request validation](#request-validation). + +### 403 vs. 404 + +- Return `403` for attempts to read, write, or act upon resources the user is allowed to know exist. +- Return `404` for attempts to read, write, or act upon resources the user should _not_ know exist. + +### 404 vs. 422 + +- If the resource specified by the URL is **not found**, or the caller should not know it exists, + APIs `MUST` return `404`. +- If the resource specified by the URL is **found**, but a resource referenced within the payload + does not exist (or the caller should not know it exists), APIs `MUST` return `422`. + +### 500 vs. 503 + +- Return `500` for unexpected errors that occur within the service that the client cannot fix. By + definition, a `500` is a bug. +- Return `503` if some dependency of the service is down or unreachable or times out. + +## Resource fields + +Every resource `MUST` include the following fields: + +| Field | Description | Type | +| ------------ | -------------------------------------------------------------------------------- | -------- | +| `attributes` | Details about the resource. | `object` | +| `id` | The unique ID of the resource. Even if the ID is numeric, it `MUST` be a string. | `string` | +| `type` | The _singular_ name of the resource (e.g. `user`). | `string` | + +The only exception is when a resource is being created via `POST` against an API that will generate +the ID. In this case, the `id` field `MUST` be omitted. + +## Standard fields + +When relevant, resources `MUST` use the following field names: + +| Field | Description | Type | +| ----------- | ---------------------------------------------------------------------------------------------- | -------- | +| `createdAt` | The date/time the resource was created, in ISO 8601 format. | `string` | +| `createdBy` | The ID of the user that created the resource. | `string` | +| `deletedAt` | The date/time the resource was deleted, in ISO 8601 format. See [Soft deletes](#soft-deletes). | `string` | +| `deletedBy` | The ID of the user that deleted the resource. | `string` | +| `updatedAt` | The date/time the resource was last updated, in ISO 8601 format. | `string` | +| `updatedBy` | The ID of the user that last updated the resource. | `string` | +| `version` | The current version of the resource. See [Optimistic concurrency](#optimistic-concurrency). | `string` | + +## Verbs + +APIs `MUST` use the standard verb semantics: + +| Verb | Meaning | +| -------- | ----------------------------------------------------------------------------------------------------------------- | +| `DELETE` | Delete a resource. | +| `GET` | Fetch a resource. Safe, idempotent, and never changes state. | +| `PATCH` | Partially update a resource. | +| `POST` | Create a resource. | +| `PUT` | Update a resource with "completely replace" semantics. APIs that support create-or-update `MUST` do so via `PUT`. | + +The only exceptions are [actions](#acting-upon-resources), [advanced queries](#advanced-queries), +[bulk updates](#bulk-updates), and [bulk deletes](#bulk-deletes), which use `POST` without creating +a resource. + +## Creating resources + +- HTTP verb `MUST` be `POST`. +- API path `SHOULD` be like `/api/v1/{resource plural}`. + +**Example** + +``` +POST /api/v1/users +Content-Type: application/json +``` + +```json +{ + "data": { + "attributes": { + "firstName": "Bob", + "lastName": "Smith" + }, + "type": "user" + } +} +``` + +**Success responses** + +Upon success, APIs `SHOULD` return `201` but `MAY` return `202` or `204`. + +| Status | Description | Response | +| ---------------- | ------------------------------------ | ----------------------------------------------------------- | +| `201 Created` | Resource was created. | The latest representation of the resource. | +| `202 Accepted` | Resource is scheduled to be created. | The [job](#jobs) that was scheduled to create the resource. | +| `204 No Content` | Resource was created. | Nothing. | + +## Read many + +- HTTP verb `MUST` be `GET`. +- API path `SHOULD` be like `/api/v1/{resource plural}`. + +**Example** + +``` +GET /api/v1/users +Accept: application/json +``` + +```json +{ + "data": [ + { + "attributes": { + "firstName": "Bob", + "lastName": "Smith" + }, + "id": "62bed180-1f78-45d4-8a56-c996936a2947", + "type": "user" + }, + { + "attributes": { + "firstName": "Alice", + "lastName": "Jones" + }, + "id": "cacba8c1-29fa-4018-8950-acd400ec76b7", + "type": "user" + } + ] +} +``` + +**Success responses** + +Upon success, APIs `MUST` return `200`. + +| Status | Description | Response | +| -------- | ----------------------- | ------------------------ | +| `200 OK` | Request was successful. | The resources requested. | + +**See also:** + +- [Advanced queries](#advanced-queries) +- [Filtering](#filtering) +- [Paging](#paging) +- [Sorting](#sorting) +- [Sparse fieldsets](#sparse-fieldsets) + +## Read one + +- HTTP verb `MUST` be `GET`. +- API path `SHOULD` be like `/api/v1/{resource plural}/{id}`. + +**Example** + +``` +GET /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 +Accept: application/json +``` + +```json +{ + "data": { + "attributes": { + "firstName": "Bob", + "lastName": "Smith" + }, + "id": "62bed180-1f78-45d4-8a56-c996936a2947", + "type": "user" + } +} +``` + +**Success responses** + +Upon success, APIs `MUST` return `200`. + +| Status | Description | Response | +| -------- | ----------------------- | ----------------------- | +| `200 OK` | Request was successful. | The resource requested. | + +## Updating resources + +- HTTP verb `MUST` be `PUT`. +- API path `SHOULD` be like `/api/v1/{resource plural}/{id}`. + +**Example** + +``` +PUT /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "data": { + "attributes": { + "firstName": "Bob", + "lastName": "Smith" + }, + "id": "62bed180-1f78-45d4-8a56-c996936a2947", + "type": "user" + } +} +``` + +**Success responses** + +Upon success, APIs `SHOULD` return `200` but `MAY` return `202` or `204`. + +| Status | Description | Response | +| ---------------- | ---------------------------------------- | ----------------------------------------------------------- | +| `200 OK` | The resource was updated. | The latest representation of the resource. | +| `202 Accepted` | The resource is scheduled to be updated. | The [job](#jobs) that was scheduled to update the resource. | +| `204 No Content` | The resource was updated. | Nothing. | + +See also: [Partial updates](#partial-updates) + +## Partial updates + +Services `SHOULD NOT` support partial updates — see [the FAQ](#frequently-asked-questions) for why. +A service that _does_ support them `MUST` use `PATCH` and `MUST` ignore any field that isn't +specified. + +**Example** + +``` +PATCH /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "data": { + "attributes": { + "firstName": "Bobby" + }, + "id": "62bed180-1f78-45d4-8a56-c996936a2947", + "type": "user" + } +} +``` + +**Success responses** + +Upon success, APIs `SHOULD` return `200` but `MAY` return `202` or `204`. + +| Status | Description | Response | +| ---------------- | ---------------------------------------- | ----------------------------------------------------------- | +| `200 OK` | The resource was updated. | The latest representation of the resource. | +| `202 Accepted` | The resource is scheduled to be updated. | The [job](#jobs) that was scheduled to update the resource. | +| `204 No Content` | The resource was updated. | Nothing. | + +## Optimistic concurrency + +- APIs that update resources `SHOULD` implement + [optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) to detect + concurrent modifications. Those that do `MUST` do so using a `version` field marked `required` and + not `readOnly`. +- The `version` field `SHOULD` be a simple integer that is incremented after every successful update + but, regardless of what value is used, the field `MUST` be formatted as a string. +- If the value provided does not match the stored version, the API `MUST` respond `409 Conflict`. +- Upon successfully updating the resource, the API `MUST` update the version identifier. + +## Deleting resources + +- HTTP verb `MUST` be `DELETE`. +- API path `SHOULD` be like `/api/v1/{resource plural}/{id}`. + +**Example** + +``` +DELETE /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 +``` + +**Success responses** + +Upon success, APIs `SHOULD` return `204` but `MAY` return `202`. + +| Status | Description | Response | +| ---------------- | ---------------------------------------- | ----------------------------------------------------------- | +| `202 Accepted` | The resource is scheduled to be deleted. | The [job](#jobs) that was scheduled to delete the resource. | +| `204 No Content` | The resource was deleted. | Nothing. | + +### Soft deletes + +Services `MAY` support "soft deletes" for any number of reasons. Services that do, `SHOULD` follow +the following guidelines: + +- Delete APIs perform a soft delete by default and read-many APIs exclude soft-deletes by default. +- If callers can request that a delete be "hard", callers pass `permanent=true` query parameter. +- If callers can request that soft-deletes be included in responses, callers pass + `includeDeleted=true` query parameter. +- Soft-deleted resources `SHOULD` carry a `deletedAt` field. + +**Examples** + +``` +DELETE /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947?permanent=true +GET /api/v1/users?includeDeleted=true +``` + +## Acting upon resources + +APIs that don't fit cleanly into the CRUD paradigm can typically be modeled as **actions**. + +- HTTP verb `MUST` be `POST`. +- API path `SHOULD` be like `/api/v1/{resource plural}/{id}/actions/{action}`. + +``` +POST /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947/actions/send-email +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "subject": "Hello", + "body": "World!" +} +``` + +> Unlike the CRUD APIs, _actions_ impose no restrictions on the shape of the JSON posted. + +**Success responses** + +Upon success, APIs `SHOULD` return `204` but `MAY` return `200` or `202`. + +| Status | Description | Response | +| ---------------- | -------------------------- | ---------------------------------------------------------- | +| `200 OK` | The action was successful. | The latest representation of the resource. | +| `202 Accepted` | The action is scheduled. | The [job](#jobs) that was scheduled to execute the action. | +| `204 No Content` | The action was successful. | Nothing. | + +## Filtering + +- Read-many APIs that allow callers to specify search criteria `MUST` do so via one or more + `filter[{name}]` query parameters where the `name` `SHOULD` be the same as one of the resource + attributes. +- APIs `MAY` support wildcard matching. Those that do `MUST` support `*` for "starts with", "ends + with", and "contains". +- APIs that support filtering by an attribute of an attribute `MAY` use dot-notation (e.g. + `address.city`). + +### Multi-value parameters + +APIs that support specifying multiple values for a query parameter `MUST` do so via a +comma-delimited list. This allows APIs that started with single-value query parameters to evolve to +supporting multiple-values without changing the external-facing contract. When multiple values are +provided, "or" query semantics `MUST` be applied. + +### Ranges + +Query parameters that allow the caller to specify a range of values should do so using two values, +separated by commas, and using `[` and `]` to represent **inclusive** begin and end, and `(` and `)` +to represent **exclusive** begin and end. An asterisk `*` may be used to represent no limit. + +Ranges are also how "less than", "greater than", "less than or equal to", and "greater than or equal +to" are implemented. + +### Examples + +| Example | Description | +| --------------------------------------------------------------- | ----------------------------------------------- | +| `filter[lastName]=Smith` | Users whose last name is 'Smith'. | +| `filter[lastName]=S*` | Users whose last name begins with 'S'. | +| `filter[lastName]=*th` | Users whose last name ends with 'th'. | +| `filter[lastName]=*mi*` | Users whose last name contains 'mi'. | +| `filter[lastName]=Smith,Jones` | Users whose last name is 'Smith' or 'Jones'. | +| `filter[failedLoginCount]=[3,*)` | Users who have 3 or more failed login attempts. | +| `filter[birthDate]=(*,2000-01-01T00:00:00Z)` | Users born before the year 2000. | +| `filter[birthDate]=[2000-01-01T00:00:00Z,2001-01-01T00:00:00Z)` | Users born in the year 2000. | + +See also [Advanced Queries](#advanced-queries). + +## Paging + +APIs `SHOULD` support paging and those that do `MUST` support either **offset/limit** style paging +or **cursor** style paging. + +### Offset/limit style paging + +APIs that implement offset/limit style paging do so via the following query parameters: + +| Parameter | Description | +| -------------- | ------------------------------------- | +| `page[number]` | The page number to return, one-based. | +| `page[size]` | The number of resources to return. | + +Paging metadata `MUST` be populated within the `meta` field of responses as follows: + +| Field | Description | +| ------------ | --------------------------------------------------- | +| `pageCount` | The total number of pages. | +| `pageNumber` | The current page number. | +| `pageSize` | The current page size. | +| `totalCount` | The total number of resources matching the request. | + +**Example** + +``` +GET /api/v1/users?page[number]=1&page[size]=2 +Accept: application/json +``` + +```json +{ + "data": [ + { + "attributes": { + "firstName": "Bob", + "lastName": "Smith" + }, + "id": "62bed180-1f78-45d4-8a56-c996936a2947", + "type": "user" + }, + { + "attributes": { + "firstName": "Alice", + "lastName": "Jones" + }, + "id": "cacba8c1-29fa-4018-8950-acd400ec76b7", + "type": "user" + } + ], + "meta": { + "pageCount": 5, + "pageNumber": 1, + "pageSize": 2, + "totalCount": 9 + } +} +``` + +### Cursor-style paging + +APIs that implement cursor-style paging do so via the following query parameters: + +| Parameter | Description | +| ------------- | ------------------------------------------------------------------ | +| `page[size]` | The number of resources to return. | +| `page[after]` | An opaque cursor. Returns the resources that follow that position. | + +A cursor `MUST` be treated as opaque. Callers `MUST NOT` construct, parse, or modify one, and a +service `MAY` change its encoding at any time without a new API version. + +Paging metadata `MUST` be populated within the `meta` field of responses as follows: + +| Field | Description | +| ---------- | -------------------------------------------------------------- | +| `pageSize` | The current page size. | +| `hasMore` | Whether more resources follow this page. | +| `cursor` | The cursor to pass as `page[after]` to retrieve the next page. | + +**Example** + +``` +GET /api/v1/users?page[size]=2&page[after]=dXNlcnM6NjJiZWQxODA +Accept: application/json +``` + +```json +{ + "data": [ + { + "attributes": { + "firstName": "Bob", + "lastName": "Smith" + }, + "id": "62bed180-1f78-45d4-8a56-c996936a2947", + "type": "user" + }, + { + "attributes": { + "firstName": "Alice", + "lastName": "Jones" + }, + "id": "cacba8c1-29fa-4018-8950-acd400ec76b7", + "type": "user" + } + ], + "meta": { + "cursor": "dXNlcnM6Y2FjYmE4YzEtMjlmYQ", + "hasMore": true, + "pageSize": 2 + } +} +``` + +The final page `MUST` return `hasMore` as `false` and `MUST` omit `cursor`. + +## Sorting + +APIs that support sorting `MUST` do so using a single query parameter named `sort` whose value is a +comma-delimited list of field names to sort by. Any field preceded by a hyphen `MUST` be interpreted +as descending. + +**Examples** + +| Example | Description | +| ------------------------- | ---------------------------------------------------- | +| `sort=lastName` | Sort by the user's last name ascending. | +| `sort=-lastName` | Sort by the user's last name descending. | +| `sort=lastName,firstName` | Sort by the user's last name and then by first name. | + +## Sparse fieldsets + +APIs `MAY` allow callers to specify which fields to return. Those that do `MUST` accept a `fields` +query parameter whose value is a comma-delimited list of the fields to include. `id` and `type` are +always returned. + +**Example** + +``` +GET /api/v1/users?fields=firstName,lastName +``` + +## Advanced queries + +The `filter` query parameters cover simple predicates, but they cannot express arbitrary `AND` and +`OR` combinations, and a long expression will exceed the practical limit on URL length. APIs that +need richer queries `MUST` expose a **search** operation that carries the expression in the request +body. + +- HTTP verb `MUST` be `POST`. +- API path `MUST` be like `/api/v1/{resource plural}:search`. + +The expression is written in [JSON Logic](https://jsonlogic.com/) and placed in a top-level `filter` +field. JSON Logic is adopted for its notation only. Its full operator set includes control flow, +iteration, and arithmetic, none of which belong in a query, so only the following operators are +supported: + +| Operator | Meaning | +| -------------------- | --------------------------------------------------- | +| `var` | Reference a field of the resource. | +| `==`, `!=` | Equal, not equal. | +| `>`, `>=`, `<`, `<=` | Numeric and date comparison. | +| `in` | Membership in a list, or a substring match. | +| `and`, `or` | Boolean composition, each taking two or more terms. | +| `!` | Negation. | + +A request that uses any other operator, or references a field that does not exist, `MUST` be +rejected with `422`. + +**Example** + +Find active or invited users whose KDF iterations are below the current minimum: + +``` +POST /api/v1/users:search?page[size]=50&sort=-createdAt +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "filter": { + "and": [ + { "<": [{ "var": "kdfIterations" }, 600000] }, + { "in": [{ "var": "status" }, ["ACTIVE", "INVITED"]] } + ] + } +} +``` + +The response `MUST` be identical in shape to the equivalent [read-many](#read-many) request. + +[Learn more about JSON Logic](https://jsonlogic.com/) + +**Success responses** + +Upon success, APIs `MUST` return `200`. + +| Status | Description | Response | +| -------- | -------------------------- | ----------------------------------------- | +| `200 OK` | The search was successful. | The matching resources, paged and sorted. | + +## Bulk updates + +Bulk updates apply the same change to every resource matching a filter. + +- HTTP verb `MUST` be `POST`. +- API path `MUST` be like `/api/v1/{resource plural}:bulk-update`. +- `filter` `MUST` be present and use the [advanced query](#advanced-queries) expression language. +- An `update` field contains the fields being changed. + +**Example** + +``` +POST /api/v1/users:bulk-update +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "filter": { + "in": [{ "var": "status" }, ["INVITED", "PENDING"]] + }, + "update": { + "status": "DISABLED" + } +} +``` + +**Success responses** + +Upon success, APIs `SHOULD` return `202` but `MAY` return `200`. + +| Status | Description | Response | +| -------------- | ------------------------------------------ | ------------------------------------------------------------ | +| `200 OK` | The resources were updated. | `meta.affectedCount`, the number of resources updated. | +| `202 Accepted` | The resources are scheduled to be updated. | The [job](#jobs) that was scheduled to update the resources. | + +## Bulk deletes + +Bulk deletes delete every resource matching a filter. + +- HTTP verb `MUST` be `POST`. +- API path `MUST` be like `/api/v1/{resource plural}:bulk-delete`. +- `filter` `MUST` be present and use the [advanced query](#advanced-queries) expression language. + +**Example** + +``` +POST /api/v1/users:bulk-delete +Accept: application/json +Content-Type: application/json +``` + +```json +{ + "filter": { + "and": [ + { "==": [{ "var": "status" }, "INVITED"] }, + { "<": [{ "var": "invitedAt" }, "2026-06-01T00:00:00Z"] } + ] + } +} +``` + +**Success responses** + +Upon success, APIs `SHOULD` return `202` but `MAY` return `200`. + +| Status | Description | Response | +| -------------- | ------------------------------------------ | ------------------------------------------------------------ | +| `200 OK` | The resources were deleted. | `meta.affectedCount`, the number of resources deleted. | +| `202 Accepted` | The resources are scheduled to be deleted. | The [job](#jobs) that was scheduled to delete the resources. | + +## Request validation + +Requests `MUST` be exhaustively validated before attempting to process the request and `SHOULD` +return all errors at once (not stop after the first error is encountered). Exhaustive validation +includes the following: + +- Every required field, parameter, and header is present, not set to null, and not an empty string. +- The data type of every field, parameter, and header is the correct data type. In the case of + parameters and headers, since those always originate as strings, it also means "can be converted + to the declared type". +- Field names specified by `sort` or `fields` parameters are valid field names. +- Values provided for constrained fields are among the enumerated values. +- Values provided for fields that restrict the minimum value, the maximum value, and/or the maximum + length are within those constraints. +- Identifiers that refer to other resources, whether managed by the service or some other service, + are valid. + +> With some enhancements, developers should get all of the above "for free" from the +> `Bitwarden.Server.Sdk` which will **guarantee** that no request reaches developer code that +> doesn't conform to the declared request model. + +## Errors + +Errors `MUST` be returned as an array of error objects within a top-level `errors` field. Each error +populates the following fields, except that at most one `source` member applies to any one error: + +| Field | Description | Type | +| ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------- | +| `code` | A stable, machine-readable error code. | `string` | +| `detail` | A human-readable explanation specific to this occurrence. May be localized. | `string` | +| `id` | A unique identifier for this particular occurrence of the problem. | `string` | +| `source` | A reference to the primary source of the error. | `object` | +| `source.header` | The name of the request header that caused the error. | `string` | +| `source.parameter` | The query parameter that caused the error. | `string` | +| `source.pointer` | A [JSON Pointer](https://www.rfc-editor.org/info/rfc6901/) to the field in error (e.g. `/data/attributes/title`). | `string` | +| `status` | The HTTP status code applicable to this problem. | `string` | +| `title` | A short, human-readable summary of the problem that doesn't change from occurrence to occurrence. | `string` | + +**Example** + +```json +{ + "errors": [ + { + "id": "9f3c1e2a-7d40-4c8b-9b17-2f5a1c6e83d1", + "status": "422", + "code": "resource-not-found", + "title": "Referenced resource does not exist", + "detail": "'e3d2eb3e-755c-41cd-86f0-0e0649043ef6' is not a group in this organization.", + "source": { + "pointer": "/data/attributes/groups/0" + } + } + ] +} +``` + +> A `500` response `MUST NOT` include any detail about the failure. `title` and `detail` `MUST` be +> generic, and `source` `MUST` be omitted. Exception messages, stack traces, type names, connection +> strings, and dependency identities are all disclosure risks and belong in traces and logs, which +> are not reachable by the caller. The `id` field is how a caller and an operator correlate a report +> with the logged detail. + +## Jobs + +APIs that accept work to be completed asynchronously return `202` along with a **job** — a resource +representing the accepted work and its progress. A formal standard for jobs is forthcoming. + +## Deprecation + +Deprecation announces that an API should no longer be called, tells callers what they _should_ be +calling, instead, and, ideally, how much time they have to migrate. + +- Every deprecated API `MUST` be marked `deprecated: true` in the service's OpenAPI spec and the + `description` field `MUST` name the replacement. +- Every response from a deprecated API `MUST` include the `Deprecation` and `Sunset` headers and + `SHOULD` include a `Link` header naming the replacement. + +> Learn more about the [Deprecation](https://www.rfc-editor.org/info/rfc9745/) and +> [Sunset](https://www.rfc-editor.org/info/rfc8594/) headers. + +**Example** + +```http +Deprecation: @1688169599 +Sunset: Sun, 30 Jun 2024 23:59:59 GMT +Link: ; rel="successor-version" +``` + +| Header | Description | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `Deprecation` | When the operation became deprecated, as an HTTP structured field date — `@` followed by seconds since the Unix epoch. | +| `Sunset` | When it will stop working, as an HTTP date. It `MUST` be a real date, and it `MUST NOT` pass without either removal or a published extension. | +| `Link` | Points at the replacement. | + +## Divergences from JSON:API + +- Request and responses use the `application/json` media type, not `application/vnd.api+json`. +- We do not support [compound documents](https://jsonapi.org/format/#document-compound-documents) or + the related `include` query parameter. +- Paged responses are not required to include `links`. +- For sparse fieldsets, callers specify the fields to include via a `fields` query parameter. We do + not support the `fields[{type}]` form. + +## Frequently asked questions + +> Why JSON:API? + +We believe an established standard, with thoughtful answers to every API question, will be more +robust than any standard we might invent ourselves. It is widely adopted among some of the largest +SaaS vendors in the industry including [Datadog](https://docs.datadoghq.com/api/latest). + +We adopt the standard selectively, however, to get most of the benefits of JSON:API without the +burden of full conformance. + +> Why is there an envelope? Can't the API just return the object? + +1. The envelope gives pagination, counts, and other response metadata somewhere to live that is not + mixed in with the resource itself. +1. It is a serialization concern, not a programming model concern. Handlers take and return plain + model classes; how those serialize is the framework's business. No application code should ever + see `data` or `attributes`. + +> Why are internal APIs versioned? Public APIs aren't. + +We feel strongly that internal APIs should be formally versioned. Without formal versioning, every +change must be additive, which means the shape can never change and every new field is optional. +Contracts constrained like that get weaker over time, and what we _want_ to express eventually +cannot be expressed, because we have committed ourselves to "additive changes only". This rules out +adopting the existing public API conventions, which are expressly unversioned. + +> Why do I have to specify the ID in the path _and_ the JSON body? + +1. So that the JSON is comprehensive and self-describing. +1. Establishing just one rule to "always include it" is easier to remember than multiple rules for + when it is required and when it is not required. + +> Why do I have to specify the type? You can infer that from the URL path. + +1. So that the JSON is comprehensive and self-describing. +1. Establishing just one rule to "always include it" is easier to remember than multiple rules for + when it is required and when it is not required. + +> Why should empty strings be treated like `null`? + +A system where `""` and `null` mean two different things requires every caller, every service, and +every database column to agree on the distinction. This can be a challenge to preserve as data gets +serialized to and from various representations across various languages and, in our experience, this +is one class of bug that can be entirely eliminated by just treating them the same. + +> Why shouldn't we support partial updates? + +1. `PUT` with completely-replace semantics is simpler to implement and far more common. +1. UI teams tend to prefer read-modify-write semantics which works well with `PUT`. +1. Services that support `PATCH` frequently have to _also_ support `PUT` which creates two endpoints + to do the same thing. +1. It is hard to tell the difference between "null this field out" vs. "do not update this field". + +To be clear, teams _can_ support partial updates. We just think you will be better served by just +sticking to simple updates. + +> How do I support just adding or removing an element from a collection? + +You `MAY` implement the [JSON Patch](https://www.rfc-editor.org/info/rfc6902/) specification, which +was designed for exactly this. However, we think an [action](#acting-upon-resources) works just as +well and is simpler: + +``` +POST /api/v1/groups/62bed180-1f78-45d4-8a56-c996936a2947/actions/add-collection +``` + +> Why are we standardizing on JSON Logic instead of OData? + +1. OData's `$filter` is a string expression language, so both building and parsing it require string + handling where JSON is already right there. +1. Composing a filter by concatenating strings means quoting and escaping, which is how injection + bugs happen. Composing a JSON object does not. +1. OData brings a great deal more than filtering — metadata documents, `$expand`, batch, its own + conventions — and we only want the filtering. From 0c2afede6a61976b375dfc4e3d58b09545b4ae6b Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 03:58:19 -0400 Subject: [PATCH 02/23] Allow for breaking changes to fix security issues --- docs/architecture/server/internal-api-standards.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 3403cd52b..ace3884ff 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -116,19 +116,19 @@ duplicated in the URL path. ## Breaking changes and versioning -APIs `MUST NOT` make **breaking changes**. A breaking change is defined as follows: +APIs `SHOULD NOT` make **breaking changes**. A breaking change is defined as follows: 1. Removing (or relocating) a field. 1. Changing the datatype of a field. 1. Making an optional field required. 1. Adding additional constraints to a field. -The lone exception to this rule is when the constraints that need to be added are fixing a bug. In -this case, even though it is technically a "breaking change", API versioning is not required: the -constraint may be added to the existing version of the API. +The lone exception to this rule are security and cryptographic issues. Such issues `SHOULD` be fixed +"in place", without creating new versions of the API, even if the changes would technically be +considered breaking changes. -If changes need to be made that _would_ be breaking changes, a new version of the API must be -created and the old one [deprecated](#deprecation). +Otherwise if changes need to be made that _would_ be breaking changes, a new version of the API must +be created and the old one [deprecated](#deprecation). > **Adding a value to a constrained field deserves a second look.** It is additive, so it is not a > breaking change by the definition above, and a caller that treats the field as an open string is From bdb4fd92600ec0548a6b9d5de0cb341620a38f92 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 04:41:54 -0400 Subject: [PATCH 03/23] Remove "removing a field" as a breaking change --- docs/architecture/server/internal-api-standards.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index ace3884ff..9f8a2b0f3 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -118,9 +118,9 @@ duplicated in the URL path. APIs `SHOULD NOT` make **breaking changes**. A breaking change is defined as follows: -1. Removing (or relocating) a field. -1. Changing the datatype of a field. +1. Adding a new, required field. 1. Making an optional field required. +1. Changing the datatype of a field. 1. Adding additional constraints to a field. The lone exception to this rule are security and cryptographic issues. Such issues `SHOULD` be fixed From 7a4da3b2e6dbdbebe7f71dbf5b1e7a87763c36d6 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 05:31:30 -0400 Subject: [PATCH 04/23] Tweaks to breaking changes in response to PR review. --- docs/architecture/adr/0036-internal-api-standards.md | 5 +++-- docs/architecture/server/internal-api-standards.md | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/architecture/adr/0036-internal-api-standards.md b/docs/architecture/adr/0036-internal-api-standards.md index be8e0a6be..9a00df973 100644 --- a/docs/architecture/adr/0036-internal-api-standards.md +++ b/docs/architecture/adr/0036-internal-api-standards.md @@ -130,8 +130,9 @@ Chosen option: **Adopt JSON:API selectively with versioning**. robust than any standard we might invent ourselves. It is widely adopted among some of the largest SaaS vendors in the industry including [ART19](https://marketplace.apilayer.com/art19_content-api) (an Amazon company) and [Datadog](https://docs.datadoghq.com/api/latest). -- We feel strongly that internal APIs should be formally versioned. Without formal versioning, every - change must be additive, which means the shape can never change and every new field is optional. +- We feel strongly that internal APIs should be formally versioned. Without formal versioning, a + change to an existing contract has to be either additive or coordinated with every consumer's + release cycle, which in practice means the shape rarely changes and every new field is optional. Contracts constrained like that get weaker over time, and what we _want_ to express eventually cannot be expressed, because we have committed ourselves to "additive changes only". This rules out adopting the existing public API conventions, which are expressly unversioned. diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 9f8a2b0f3..86d73c6a4 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -116,13 +116,19 @@ duplicated in the URL path. ## Breaking changes and versioning -APIs `SHOULD NOT` make **breaking changes**. A breaking change is defined as follows: +APIs `SHOULD NOT` make **breaking changes**. A breaking change is any change that causes a request +that is valid today to be rejected tomorrow. In practice, that means: 1. Adding a new, required field. 1. Making an optional field required. 1. Changing the datatype of a field. 1. Adding additional constraints to a field. +The verb is `SHOULD NOT` rather than `MUST NOT` because these are internal APIs and we own every +caller. A team `MAY` make a breaking change in place when it can account for every caller — either +because the change has been coordinated with them, or because the rejection surfaces somewhere the +caller or the user can act on it. + The lone exception to this rule are security and cryptographic issues. Such issues `SHOULD` be fixed "in place", without creating new versions of the API, even if the changes would technically be considered breaking changes. From 4acc0407f335239c258ae1fa97c26c12e76118a1 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 05:46:45 -0400 Subject: [PATCH 05/23] Make it clear any bug should be fixed "in place" --- docs/architecture/server/internal-api-standards.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 86d73c6a4..a8eb51861 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -129,12 +129,11 @@ caller. A team `MAY` make a breaking change in place when it can account for eve because the change has been coordinated with them, or because the rejection surfaces somewhere the caller or the user can act on it. -The lone exception to this rule are security and cryptographic issues. Such issues `SHOULD` be fixed -"in place", without creating new versions of the API, even if the changes would technically be -considered breaking changes. +Bugs, however, `SHOULD` be fixed "in place", without creating new versions of the API, even if the +changes would technically be considered breaking changes. -Otherwise if changes need to be made that _would_ be breaking changes, a new version of the API must -be created and the old one [deprecated](#deprecation). +Otherwise, if changes need to be made that _would_ be breaking changes, a new version of the API +must be created and the old one [deprecated](#deprecation). > **Adding a value to a constrained field deserves a second look.** It is additive, so it is not a > breaking change by the definition above, and a caller that treats the field as an open string is From 1ff59023e25091198d27d22635f85116fe21857e Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 06:50:34 -0400 Subject: [PATCH 06/23] Specify escaping for delimiters in filter values Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index a8eb51861..b330398dd 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -570,6 +570,12 @@ comma-delimited list. This allows APIs that started with single-value query para supporting multiple-values without changing the external-facing contract. When multiple values are provided, "or" query semantics `MUST` be applied. +A comma that is part of a value `MUST` be escaped with a backslash — +`filter[displayName]=Smith\, Jr` is the single value `Smith, Jr`. This applies only where the caller +is supplying multiple values or a [range](#ranges); a parameter that takes a single value is never +split, so a comma in its value needs no escaping. An asterisk that is part of a value `MUST` +likewise be escaped wherever the API supports wildcard matching. + ### Ranges Query parameters that allow the caller to specify a range of values should do so using two values, From 79c6008e8b2e1966f2cd51438cf3a232c3e07285 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 07:01:02 -0400 Subject: [PATCH 07/23] Add two missing divergences from JSON:API Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index b330398dd..97876ff3a 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -978,6 +978,10 @@ Link: ; rel="successor-version" - Paged responses are not required to include `links`. - For sparse fieldsets, callers specify the fields to include via a `fields` query parameter. We do not support the `fields[{type}]` form. +- Unrecognized query parameters are ignored rather than rejected with `400`, and the result-shaping + subset is rejected with `422`. See + [Unrecognized fields, query parameters, and headers](#unrecognized-fields-query-parameters-and-headers). +- Implementation-specific query parameters are not required to contain a non-`a-z` character. ## Frequently asked questions From 3c78758b0aa037143960eb56f8037d82adef92d5 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 07:05:11 -0400 Subject: [PATCH 08/23] Specify read-one behavior for soft-deleted resources Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 97876ff3a..630afd077 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -509,6 +509,8 @@ Services `MAY` support "soft deletes" for any number of reasons. Services that d the following guidelines: - Delete APIs perform a soft delete by default and read-many APIs exclude soft-deletes by default. +- Read-one APIs `SHOULD` return `404` for a soft-deleted resource unless the caller passes + `includeDeleted=true`. - If callers can request that a delete be "hard", callers pass `permanent=true` query parameter. - If callers can request that soft-deletes be included in responses, callers pass `includeDeleted=true` query parameter. From 4cd82cc98360045db23c353b72ff519f6a62d6eb Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 07:06:53 -0400 Subject: [PATCH 09/23] Align advanced query field rejection with filter rule Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 630afd077..0a917fbea 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -768,7 +768,7 @@ supported: | `and`, `or` | Boolean composition, each taking two or more terms. | | `!` | Negation. | -A request that uses any other operator, or references a field that does not exist, `MUST` be +A request that uses any other operator, or references a field the API does not support, `MUST` be rejected with `422`. **Example** From 10c2070a23288003e34f7b453afc0fb829ff1db1 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 07:09:55 -0400 Subject: [PATCH 10/23] Mark versioning requirement as an RFC 2119 keyword Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 0a917fbea..a72ce0368 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -133,7 +133,7 @@ Bugs, however, `SHOULD` be fixed "in place", without creating new versions of th changes would technically be considered breaking changes. Otherwise, if changes need to be made that _would_ be breaking changes, a new version of the API -must be created and the old one [deprecated](#deprecation). +`MUST` be created and the old one [deprecated](#deprecation). > **Adding a value to a constrained field deserves a second look.** It is additive, so it is not a > breaking change by the definition above, and a caller that treats the field as an open string is From 0d4e7dbe1cbf2d3c91326c64919bf8c523a68072 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 07:13:33 -0400 Subject: [PATCH 11/23] Cover response-side breaking changes Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index a72ce0368..3e004f36d 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -117,12 +117,14 @@ duplicated in the URL path. ## Breaking changes and versioning APIs `SHOULD NOT` make **breaking changes**. A breaking change is any change that causes a request -that is valid today to be rejected tomorrow. In practice, that means: +that is valid today to be rejected tomorrow, or a response that a caller can process today to become +unprocessable. In practice, that means: 1. Adding a new, required field. 1. Making an optional field required. 1. Changing the datatype of a field. 1. Adding additional constraints to a field. +1. Removing a field from a response. The verb is `SHOULD NOT` rather than `MUST NOT` because these are internal APIs and we own every caller. A team `MAY` make a breaking change in place when it can account for every caller — either From cf0be8a3fa0ff63e550a2dfa68db9e700996fdca Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 07:26:10 -0400 Subject: [PATCH 12/23] Clarify escaping scope and flag the multi-value evolution hazard Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 3e004f36d..b3be61fa6 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -574,11 +574,15 @@ comma-delimited list. This allows APIs that started with single-value query para supporting multiple-values without changing the external-facing contract. When multiple values are provided, "or" query semantics `MUST` be applied. -A comma that is part of a value `MUST` be escaped with a backslash — -`filter[displayName]=Smith\, Jr` is the single value `Smith, Jr`. This applies only where the caller -is supplying multiple values or a [range](#ranges); a parameter that takes a single value is never -split, so a comma in its value needs no escaping. An asterisk that is part of a value `MUST` -likewise be escaped wherever the API supports wildcard matching. +On a parameter that accepts multiple values or a [range](#ranges), a comma or backslash that is part +of a value `MUST` be escaped with a backslash — `filter[displayName]=Smith\, Jr` is the single value +`Smith, Jr`. A parameter that takes a single value is never split, so its value needs no escaping. +An asterisk `MUST` likewise be escaped wherever the API supports wildcard matching. + +> **Take care when a single-value parameter gains multi-value support.** A caller sending an +> unescaped comma will suddenly be sending two values. That is usually safe, because the evolution +> typically happens on constrained fields whose values cannot contain commas — but confirm it before +> making the change. ### Ranges From 58ead17131f2a4404efe397b3680bad91be32842 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 08:05:04 -0400 Subject: [PATCH 13/23] Require bulk deletes to soft-delete where supported Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index b3be61fa6..2b1b5b18e 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -854,6 +854,7 @@ Bulk deletes delete every resource matching a filter. - HTTP verb `MUST` be `POST`. - API path `MUST` be like `/api/v1/{resource plural}:bulk-delete`. - `filter` `MUST` be present and use the [advanced query](#advanced-queries) expression language. +- On a service that supports [soft deletes](#soft-deletes), a bulk delete `MUST` soft-delete. **Example** From 3a4f1239a47e074691a89841184ac5c1ad79239f Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 08:07:00 -0400 Subject: [PATCH 14/23] Record PUT semantics and relationships as divergences Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 2b1b5b18e..a023f79d5 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -991,6 +991,10 @@ Link: ; rel="successor-version" subset is rejected with `422`. See [Unrecognized fields, query parameters, and headers](#unrecognized-fields-query-parameters-and-headers). - Implementation-specific query parameters are not required to contain a non-`a-z` character. +- Resources are updated with `PUT` and complete-replace semantics rather than `PATCH`. `PATCH`, + where a service supports it, follows the specification's partial-update semantics. +- We do not support `relationships` objects. A reference to another resource is an `attributes` + member holding its ID. ## Frequently asked questions From eff826e0b86b4ea17d5e96eec017537eecbee738 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 08:09:13 -0400 Subject: [PATCH 15/23] Tie backslash escaping to where an escape applies Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index a023f79d5..2ce7f853c 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -574,10 +574,11 @@ comma-delimited list. This allows APIs that started with single-value query para supporting multiple-values without changing the external-facing contract. When multiple values are provided, "or" query semantics `MUST` be applied. -On a parameter that accepts multiple values or a [range](#ranges), a comma or backslash that is part -of a value `MUST` be escaped with a backslash — `filter[displayName]=Smith\, Jr` is the single value -`Smith, Jr`. A parameter that takes a single value is never split, so its value needs no escaping. -An asterisk `MUST` likewise be escaped wherever the API supports wildcard matching. +On a parameter that accepts multiple values or a [range](#ranges), a comma that is part of a value +`MUST` be escaped with a backslash — `filter[displayName]=Smith\, Jr` is the single value +`Smith, Jr`. An asterisk `MUST` likewise be escaped wherever the API supports wildcard matching. +Wherever either escape applies, a backslash that is part of a value `MUST` also be escaped as `\\`. +A parameter that takes a single value is never split, so a comma in its value needs no escaping. > **Take care when a single-value parameter gains multi-value support.** A caller sending an > unescaped comma will suddenly be sending two values. That is usually safe, because the evolution From e3e3a75c33a5e7023e7afcf22bdaac9e4c7e7366 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Wed, 16 Sep 2026 08:23:29 -0400 Subject: [PATCH 16/23] Resolve partial-update and create-via-PUT ambiguities Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/server/internal-api-standards.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 2ce7f853c..937d93d34 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -428,11 +428,13 @@ Content-Type: application/json **Success responses** -Upon success, APIs `SHOULD` return `200` but `MAY` return `202` or `204`. +Upon success, APIs `SHOULD` return `200` — or `201` if the request created the resource — but `MAY` +return `202` or `204`. | Status | Description | Response | | ---------------- | ---------------------------------------- | ----------------------------------------------------------- | | `200 OK` | The resource was updated. | The latest representation of the resource. | +| `201 Created` | The resource was created. | The latest representation of the resource. | | `202 Accepted` | The resource is scheduled to be updated. | The [job](#jobs) that was scheduled to update the resource. | | `204 No Content` | The resource was updated. | Nothing. | @@ -442,7 +444,8 @@ See also: [Partial updates](#partial-updates) Services `SHOULD NOT` support partial updates — see [the FAQ](#frequently-asked-questions) for why. A service that _does_ support them `MUST` use `PATCH` and `MUST` ignore any field that isn't -specified. +specified. In a partial update, an absent field means "do not change" and an explicit `null` clears +the value. **Example** From f03dcc691f11003c667242799010ffc1a8b91c89 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Tue, 22 Sep 2026 11:50:04 -0400 Subject: [PATCH 17/23] Clarify scope --- docs/architecture/server/internal-api-standards.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index 937d93d34..d384edc03 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -8,8 +8,9 @@ sidebar_position: 3 **Audience:** Bitwarden engineers and AI agents building or consuming a service-to-service API. -**Scope.** Internal, service-to-service APIs. Bitwarden's existing public API is out of scope and is -not changing; nothing here applies to it. +**Scope.** Internal, service-to-service APIs. Bitwarden's existing public API, including the part +that is currently referred to as "internal", is out of scope and is not changing. This ADR applies +only to internal, service-to-service APIs that are not exposed to the internet. This page is the living standard, adopted in [ADR-0036](../adr/0036-internal-api-standards.md). Its rules evolve by pull request without superseding that decision. From 72a5b8ee5c231aaa70f19eeb89ddf28629176873 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Tue, 22 Sep 2026 12:03:12 -0400 Subject: [PATCH 18/23] Fix wording - this is not an ADR --- docs/architecture/server/internal-api-standards.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/internal-api-standards.md index d384edc03..6c5659f23 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/internal-api-standards.md @@ -9,8 +9,8 @@ sidebar_position: 3 **Audience:** Bitwarden engineers and AI agents building or consuming a service-to-service API. **Scope.** Internal, service-to-service APIs. Bitwarden's existing public API, including the part -that is currently referred to as "internal", is out of scope and is not changing. This ADR applies -only to internal, service-to-service APIs that are not exposed to the internet. +that is currently referred to as "internal", is out of scope and is not changing. These standards +apply only to internal, service-to-service APIs that are not exposed to the internet. This page is the living standard, adopted in [ADR-0036](../adr/0036-internal-api-standards.md). Its rules evolve by pull request without superseding that decision. From 0e3df1d475bd2169651ed72d4bf7cf186d8bbb5e Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Tue, 22 Sep 2026 12:46:00 -0400 Subject: [PATCH 19/23] Rename to service-to-service API standards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Internal" already names the client-facing API everywhere else at Bitwarden: the server repo splits its OpenAPI into public and internal documents and serves the latter at specs/internal/swagger.json, and public-api.md on this site calls the same surface the private API. The scope paragraph here had to disclaim the title to say which "internal" it meant. Service-to-service is the term ADR-0035 rule 4 now gates on and the term this document already used for the relationship, so the gate and the standard match without a mapping. Scope is stated as a caller test — whose callers are other Bitwarden services — rather than a reachability test, matching rule 4. Co-Authored-By: Claude Opus 5 (1M context) --- ... 0036-service-to-service-api-standards.md} | 44 +++++++++---------- ...md => service-to-service-api-standards.md} | 35 ++++++++------- 2 files changed, 40 insertions(+), 39 deletions(-) rename docs/architecture/adr/{0036-internal-api-standards.md => 0036-service-to-service-api-standards.md} (80%) rename docs/architecture/server/{internal-api-standards.md => service-to-service-api-standards.md} (97%) diff --git a/docs/architecture/adr/0036-internal-api-standards.md b/docs/architecture/adr/0036-service-to-service-api-standards.md similarity index 80% rename from docs/architecture/adr/0036-internal-api-standards.md rename to docs/architecture/adr/0036-service-to-service-api-standards.md index 9a00df973..993f2a003 100644 --- a/docs/architecture/adr/0036-internal-api-standards.md +++ b/docs/architecture/adr/0036-service-to-service-api-standards.md @@ -5,7 +5,7 @@ date: 2026-09-22 tags: [server, server-sdk] --- -# 0036 - Internal API standards +# 0036 - Service-to-service API standards @@ -17,8 +17,8 @@ This ADR uses [RFC 2119](https://www.rfc-editor.org/info/rfc2119/) keywords (`MU `SHOULD`, `SHOULD NOT`, `MAY`) deliberately. Anything marked `MUST` or `MUST NOT` is not negotiable at team level; a team that needs an exception brings the case to the architecture group. -**Internal API** means a service-to-service API: one Bitwarden service calling another. Bitwarden's -existing public API is out of scope and is not changing. +**Service-to-service API** means one Bitwarden service calling another. Bitwarden's existing public +API is out of scope and is not changing. ## Context and problem statement @@ -27,22 +27,22 @@ services, a service calling another service needs a wire contract, and no docume exists. One important distinction to keep in mind as we consider various options is that the APIs we are -talking about are _internal_ APIs. We control both the service that declares them and every client -that consumes them. While we _do_ still want to adhere to the principle of "no breaking changes", we -also do not want that to result in an ever-growing number of optional fields that ultimately make -these contracts less clear. +talking about are _service-to-service_ APIs. We control both the service that declares them and +every client that consumes them. While we _do_ still want to adhere to the principle of "no breaking +changes", we also do not want that to result in an ever-growing number of optional fields that +ultimately make these contracts less clear. In the same way we [refactor our code mercilessly](https://wiki.c2.com/?RefactorMercilessly), we want the liberty to be able to do the same with our APIs. Especially when they are still under active development and rapidly evolving. Said differently, we do not want the rules that restrict how public-facing APIs may evolve to -restrict how, or the pace at which, we evolve internal-only APIs. +restrict how, or the pace at which, we evolve service-to-service APIs. ## Considered options - **Adopt the existing public API conventions:** apply the same shape and the same no-versioning - posture to internal APIs. + posture to service-to-service APIs. - **Keep the existing JSON shape and add versioning:** retain today's conventions, introduce path versioning. - **Adopt [JSON:API](https://jsonapi.org/) strictly:** implement the specification in full, @@ -54,7 +54,7 @@ restrict how, or the pace at which, we evolve internal-only APIs. **Pros** -- One shape across every Bitwarden API, public and internal. +- One shape across every Bitwarden API, public and service-to-service. - Nothing new for engineers to learn, and no migration for code already written this way. **Cons** @@ -130,24 +130,24 @@ Chosen option: **Adopt JSON:API selectively with versioning**. robust than any standard we might invent ourselves. It is widely adopted among some of the largest SaaS vendors in the industry including [ART19](https://marketplace.apilayer.com/art19_content-api) (an Amazon company) and [Datadog](https://docs.datadoghq.com/api/latest). -- We feel strongly that internal APIs should be formally versioned. Without formal versioning, a - change to an existing contract has to be either additive or coordinated with every consumer's - release cycle, which in practice means the shape rarely changes and every new field is optional. - Contracts constrained like that get weaker over time, and what we _want_ to express eventually - cannot be expressed, because we have committed ourselves to "additive changes only". This rules - out adopting the existing public API conventions, which are expressly unversioned. +- We feel strongly that service-to-service APIs should be formally versioned. Without formal + versioning, a change to an existing contract has to be either additive or coordinated with every + consumer's release cycle, which in practice means the shape rarely changes and every new field is + optional. Contracts constrained like that get weaker over time, and what we _want_ to express + eventually cannot be expressed, because we have committed ourselves to "additive changes only". + This rules out adopting the existing public API conventions, which are expressly unversioned. - We adopt the standard selectively to get most of the benefits of JSON:API without the burden of full conformance. The specific standards, including where we deviate from JSON:API, are published here: -[Internal API standards](../server/internal-api-standards.md). That page is the living reference: -its rules evolve by pull request without superseding this decision, and this ADR is superseded only -if the model itself changes. +[Service-to-service API standards](../server/service-to-service-api-standards.md). That page is the +living reference: its rules evolve by pull request without superseding this decision, and this ADR +is superseded only if the model itself changes. ### Positive consequences -- One document shape across every internal API, so a consumer calling five services learns one error - format, one pagination scheme, and one filter grammar. +- One document shape across every service-to-service API, so a consumer calling five services learns + one error format, one pagination scheme, and one filter grammar. - Versioning gives a contract a way to change shape. A breaking change becomes a new version with a migration, instead of more optional parameters. - The envelope gives pagination state and other response metadata somewhere to live that is not @@ -157,7 +157,7 @@ if the model itself changes. ### Negative consequences -- Internal APIs "look different" from public APIs. +- Service-to-service APIs "look different" from public APIs. - Additional work is required to ensure the "envelopes" are largely transparent to developers when working with request and response models in code. - Partial conformance invites any deviation from the specification to be argued as allowed. We will diff --git a/docs/architecture/server/internal-api-standards.md b/docs/architecture/server/service-to-service-api-standards.md similarity index 97% rename from docs/architecture/server/internal-api-standards.md rename to docs/architecture/server/service-to-service-api-standards.md index 6c5659f23..1a81c2c9d 100644 --- a/docs/architecture/server/internal-api-standards.md +++ b/docs/architecture/server/service-to-service-api-standards.md @@ -2,18 +2,19 @@ sidebar_position: 3 --- -# Internal API standards +# Service-to-service API standards {/* cspell:ignore reate reates eletes elete pdate pdates fieldsets */} **Audience:** Bitwarden engineers and AI agents building or consuming a service-to-service API. -**Scope.** Internal, service-to-service APIs. Bitwarden's existing public API, including the part -that is currently referred to as "internal", is out of scope and is not changing. These standards -apply only to internal, service-to-service APIs that are not exposed to the internet. +**Scope.** Service-to-service APIs, whose callers are other Bitwarden services. Bitwarden's existing +public API is out of scope and is not changing, as is the internet-reachable surface our own clients +call — the part the server repository refers to as "internal". -This page is the living standard, adopted in [ADR-0036](../adr/0036-internal-api-standards.md). Its -rules evolve by pull request without superseding that decision. +This page is the living standard, adopted in +[ADR-0036](../adr/0036-service-to-service-api-standards.md). Its rules evolve by pull request +without superseding that decision. [RFC 2119](https://www.rfc-editor.org/info/rfc2119/) keywords (`MUST`, `MUST NOT`, `SHOULD`, `SHOULD NOT`, `MAY`) are used deliberately. A `MUST` or `MUST NOT` is not negotiable at team level; @@ -39,7 +40,7 @@ Standards for each type of API are documented below. ### JSON:API -Internal Bitwarden APIs are based on top of [JSON:API](https://jsonapi.org/) unless otherwise noted +Service-to-service APIs are based on top of [JSON:API](https://jsonapi.org/) unless otherwise noted in this document. Where our standards are silent, JSON:API standards are assumed. ### Well-defined APIs @@ -127,10 +128,10 @@ unprocessable. In practice, that means: 1. Adding additional constraints to a field. 1. Removing a field from a response. -The verb is `SHOULD NOT` rather than `MUST NOT` because these are internal APIs and we own every -caller. A team `MAY` make a breaking change in place when it can account for every caller — either -because the change has been coordinated with them, or because the rejection surfaces somewhere the -caller or the user can act on it. +The verb is `SHOULD NOT` rather than `MUST NOT` because these are service-to-service APIs and we own +every caller. A team `MAY` make a breaking change in place when it can account for every caller — +either because the change has been coordinated with them, or because the rejection surfaces +somewhere the caller or the user can act on it. Bugs, however, `SHOULD` be fixed "in place", without creating new versions of the API, even if the changes would technically be considered breaking changes. @@ -1020,13 +1021,13 @@ burden of full conformance. model classes; how those serialize is the framework's business. No application code should ever see `data` or `attributes`. -> Why are internal APIs versioned? Public APIs aren't. +> Why are service-to-service APIs versioned? Public APIs aren't. -We feel strongly that internal APIs should be formally versioned. Without formal versioning, every -change must be additive, which means the shape can never change and every new field is optional. -Contracts constrained like that get weaker over time, and what we _want_ to express eventually -cannot be expressed, because we have committed ourselves to "additive changes only". This rules out -adopting the existing public API conventions, which are expressly unversioned. +We feel strongly that service-to-service APIs should be formally versioned. Without formal +versioning, every change must be additive, which means the shape can never change and every new +field is optional. Contracts constrained like that get weaker over time, and what we _want_ to +express eventually cannot be expressed, because we have committed ourselves to "additive changes +only". This rules out adopting the existing public API conventions, which are expressly unversioned. > Why do I have to specify the ID in the path _and_ the JSON body? From fa49b457984361803c3fa01890d0ce88c5ea221b Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Tue, 22 Sep 2026 13:04:35 -0400 Subject: [PATCH 20/23] Make the range grammar normative Ranges was the only wire-grammar section with no RFC 2119 keyword, while Filtering, Multi-value parameters, Sorting and Sparse fieldsets are all MUST. The escape rule for commas is already normative against this syntax, so the syntax itself has to be citable in review. Same for the all-caps enum convention, which sat unmarked in a bullet that ends with a MUST. Co-Authored-By: Claude Opus 5 (1M context) --- .../architecture/server/service-to-service-api-standards.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/server/service-to-service-api-standards.md b/docs/architecture/server/service-to-service-api-standards.md index 1a81c2c9d..2265590e9 100644 --- a/docs/architecture/server/service-to-service-api-standards.md +++ b/docs/architecture/server/service-to-service-api-standards.md @@ -168,7 +168,7 @@ for. APIs `MUST` reject the request with `422` when given: - Dates `MUST` be formatted in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format (e.g. `2026-01-01T00:00:00Z`) and `MUST` include a time zone indicator and `SHOULD` standardize on UTC. -- Field values that are constrained to a fixed set of values should enumerate the valid values in +- Field values that are constrained to a fixed set of values `SHOULD` enumerate the valid values in all caps (e.g. `RED`, `GREEN`, `BLUE`) both in the OpenAPI spec and in example JSON to help distinguish these fields from free-text string fields. At runtime, however, APIs `MUST` ignore case when validating these values. @@ -592,9 +592,9 @@ A parameter that takes a single value is never split, so a comma in its value ne ### Ranges -Query parameters that allow the caller to specify a range of values should do so using two values, +Query parameters that allow the caller to specify a range of values `MUST` do so using two values, separated by commas, and using `[` and `]` to represent **inclusive** begin and end, and `(` and `)` -to represent **exclusive** begin and end. An asterisk `*` may be used to represent no limit. +to represent **exclusive** begin and end. An asterisk `*` `MAY` be used to represent no limit. Ranges are also how "less than", "greater than", "less than or equal to", and "greater than or equal to" are implemented. From a4a7675b627bc678aafd7b93f7dbb484a232a077 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Thu, 24 Sep 2026 13:37:08 -0400 Subject: [PATCH 21/23] Remove the detailed standards --- .../service-to-service-api-standards.md | 1079 ----------------- 1 file changed, 1079 deletions(-) delete mode 100644 docs/architecture/server/service-to-service-api-standards.md diff --git a/docs/architecture/server/service-to-service-api-standards.md b/docs/architecture/server/service-to-service-api-standards.md deleted file mode 100644 index 2265590e9..000000000 --- a/docs/architecture/server/service-to-service-api-standards.md +++ /dev/null @@ -1,1079 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Service-to-service API standards - -{/* cspell:ignore reate reates eletes elete pdate pdates fieldsets */} - -**Audience:** Bitwarden engineers and AI agents building or consuming a service-to-service API. - -**Scope.** Service-to-service APIs, whose callers are other Bitwarden services. Bitwarden's existing -public API is out of scope and is not changing, as is the internet-reachable surface our own clients -call — the part the server repository refers to as "internal". - -This page is the living standard, adopted in -[ADR-0036](../adr/0036-service-to-service-api-standards.md). Its rules evolve by pull request -without superseding that decision. - -[RFC 2119](https://www.rfc-editor.org/info/rfc2119/) keywords (`MUST`, `MUST NOT`, `SHOULD`, -`SHOULD NOT`, `MAY`) are used deliberately. A `MUST` or `MUST NOT` is not negotiable at team level; -a team that needs an exception brings the case to the architecture group. - -## General - -In general, RESTful APIs are resource-oriented and do one of 4 things: - -- **C**reate a resource. -- **R**ead a resource. -- **U**pdate a resource. -- **D**elete a resource. - -This is the familiar CRUD paradigm and, while there will always be exceptions, developers `SHOULD` -strive to think in these terms for every API created. Both for simplicity and consistency. - -There is, however, a 5th type of API that doesn't cleanly fit the CRUD paradigm: - -- **Act** upon a resource. - -Standards for each type of API are documented below. - -### JSON:API - -Service-to-service APIs are based on top of [JSON:API](https://jsonapi.org/) unless otherwise noted -in this document. Where our standards are silent, JSON:API standards are assumed. - -### Well-defined APIs - -A well-defined API spells out exactly how it should be called and what the caller can expect in -return - for both the happy path and the not-so-happy path. Developers `SHOULD` strive to think in -terms of resources and be on guard against **API proliferation** that can result from over-tailoring -APIs to the unique needs of this caller or that. - -In general, it is better to have one API that can be called two different ways (e.g. query -parameters) than two APIs that can only be called one way. - -## Authentication and authorization - -How a caller proves its identity, how a service authorizes an operation, and how the current -organization travels with a request are the subject of a forthcoming standard. - -## OpenAPI - -APIs `MUST` be documented in [OpenAPI](https://www.openapis.org/) format. - -- APIs `MUST` provide a description written with the API consumer as the audience in mind, free of - implementation details. -- APIs `SHOULD` provide realistic example JSON for both the request and response. -- APIs `SHOULD` document which attributes are filterable and which are sortable. -- Fields that must always be present `MUST` be marked as `required`. -- Fields whose value may be null `MUST` be marked as `nullable`. -- Fields whose value is set by the server `MUST` be marked as `readOnly`. If a request includes a - read-only field, the API `MUST` ignore it. - -### Operation identifiers - -Every operation `MUST` carry an explicit, stable `operationId`, and it `MUST` include the version -(e.g. `getGroupV1`, `listGroupsV1`, `replaceGroupV1`, `getPolicyV2`). These IDs are necessary for -stable, generated clients: - -1. Without the version, `/api/v1/groups/{id}` and `/api/v2/groups/{id}` collide into one method - name. -1. Without an _explicit_ identifier, generators invent one from the route which can be brittle. - -## API paths - -API paths `MUST` use **lowercase "kebab-case"** and conform as follows: - -1. The first element of the API path `MUST` be a namespace. By default, the namespace `SHOULD` be - `api`. -1. The second element of the API path `MUST` be the version number starting with `v1`. -1. The third element of the API path `MUST` specify the resource or resources it targets. For - resource-oriented APIs, this element `SHOULD` be the "resource plural" (e.g. `users`). -1. For resource-oriented APIs, the fourth element of the API path `SHOULD` be the ID of the resource - it targets. - -> **Why versioning in the path?** It is visible in logs, traces, routing rules and curl commands; it -> needs no content negotiation to read; and it lets two versions coexist behind one host. Header and -> media-type versioning are both defensible but harder to operate. - -**Examples** - -``` -/api/v1/users -/api/v1/users/123 -/api/v1/users/123/addresses -``` - -Paths `SHOULD` be "hackable". If `GET /api/v1/users/123/addresses/456` returns the details about -address 456 of user 123, then every parent path `SHOULD` resolve: - -- `GET /api/v1/users/123/addresses` should return all addresses of user 123. -- `GET /api/v1/users/123` should return details about user 123. -- `GET /api/v1/users` should return all users. - -Organization IDs `SHOULD NOT` appear in the path because "the current organization" is part of the -[context](#authentication-and-authorization) of almost every request and, thus, need not be -duplicated in the URL path. - -## Breaking changes and versioning - -APIs `SHOULD NOT` make **breaking changes**. A breaking change is any change that causes a request -that is valid today to be rejected tomorrow, or a response that a caller can process today to become -unprocessable. In practice, that means: - -1. Adding a new, required field. -1. Making an optional field required. -1. Changing the datatype of a field. -1. Adding additional constraints to a field. -1. Removing a field from a response. - -The verb is `SHOULD NOT` rather than `MUST NOT` because these are service-to-service APIs and we own -every caller. A team `MAY` make a breaking change in place when it can account for every caller — -either because the change has been coordinated with them, or because the rejection surfaces -somewhere the caller or the user can act on it. - -Bugs, however, `SHOULD` be fixed "in place", without creating new versions of the API, even if the -changes would technically be considered breaking changes. - -Otherwise, if changes need to be made that _would_ be breaking changes, a new version of the API -`MUST` be created and the old one [deprecated](#deprecation). - -> **Adding a value to a constrained field deserves a second look.** It is additive, so it is not a -> breaking change by the definition above, and a caller that treats the field as an open string is -> unaffected. But a generated client that deserializes the field into a closed enumeration will fail -> on a value it has never seen — and it will fail at the client, on a change that looked safe from -> the service. Consider whether the callers of that field are tolerant of unknown values before -> adding one. - -## Unrecognized fields, query parameters, and headers - -Sometimes a new, optional field, query parameter, or header is added to an API. Consumers upgrade to -new clients and start passing the new fields/parameters/headers and everything is great until a -security issue is discovered. The service is rolled back to the previous version but consumers are -still using newer versions of the client. - -To keep from having to _also_ revert all of the consumers, APIs `MUST` ignore unrecognized fields, -parameters, and headers. - -The exception is a parameter that shapes the result. Ignoring an unrecognized field means doing less -than the caller asked; ignoring one of these means returning something other than what was asked -for. APIs `MUST` reject the request with `422` when given: - -- a `filter[{name}]`, `sort`, or `fields` parameter naming a field they do not support, or -- paging parameters when the API does not support paging, or for a paging style it does not - implement. - -## JSON - -- Dates `MUST` be formatted in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format (e.g. - `2026-01-01T00:00:00Z`) and `MUST` include a time zone indicator and `SHOULD` standardize on UTC. -- Field values that are constrained to a fixed set of values `SHOULD` enumerate the valid values in - all caps (e.g. `RED`, `GREEN`, `BLUE`) both in the OpenAPI spec and in example JSON to help - distinguish these fields from free-text string fields. At runtime, however, APIs `MUST` ignore - case when validating these values. -- APIs `SHOULD` strip leading and trailing whitespace from all strings before processing them. -- APIs `MUST` treat an empty string the same as if the field is not present. -- APIs `MUST` treat a field that isn't present the same as `null`. -- APIs `SHOULD NOT` include `null` fields in responses. - -## Naming conventions - -- Field names `MUST` be in camel case (e.g. `firstName`, `lastName`). -- Fields holding a date or date/time `MUST` end in `At` (e.g. `createdAt`, `expiresAt`). -- Boolean fields `MUST NOT` be prefixed with `is` (e.g. `active`, not `isActive`). -- Fields whose value is the identifier of another resource `SHOULD NOT` be suffixed with `Id`. A - string-valued `assignedTo` is self-evidently the identifier of the user it is assigned to; - `assignedToId` adds nothing. - -## Content negotiation - -- Services `MUST` respect the `Accept` media type requested by the caller. If the caller asks for - XML and the server cannot return XML, the service `MUST` return `406 Not Acceptable`. -- Because our APIs are largely based on JSON:API, services `MUST` accept a request whose - `Content-Type` is `application/vnd.api+json`, and `MUST` honor an `Accept` of - `application/vnd.api+json`, even though the API itself only advertises `application/json`. -- Services `MUST` return `415 Unsupported Media Type` if the server can't process the specified - `Content-Type`. - -## Standard responses - -APIs `SHOULD NOT` document standard responses because these responses apply to every API and -documenting them over and over again just creates noise. The following responses are possible for -every API: - -| Response | Description | -| -------- | ------------------------------------------------------------------------------------------------------- | -| `400` | A malformed request (i.e. invalid JSON). | -| `401` | The caller isn't [recognized](#authentication-and-authorization) (i.e. not authenticated). | -| `403` | The caller isn't [authorized](#authentication-and-authorization) to invoke the API (i.e. forbidden). | -| `404` | The resource specified via the URL path does not exist (or the caller isn't allowed to know it exists). | -| `405` | The HTTP method is not allowed. | -| `406` | The API doesn't return the specified media type. | -| `409` | The attempted update conflicts with some other update (i.e. was changed since the caller last read it). | -| `415` | The API doesn't accept the specified media type. | -| `422` | An error the client can fix. | -| `429` | The caller has made too many requests. | -| `500` | An unexpected error the client cannot fix. | -| `501` | The API has just been stubbed out and has not been implemented, yet. | -| `503` | The request cannot be completed because a dependency is unavailable or timed out. | - -### 400 vs. 422 - -- Return `400` for requests that are malformed and cannot even be parsed (e.g. invalid JSON). -- Return `422` for invalid requests. See [Request validation](#request-validation). - -### 403 vs. 404 - -- Return `403` for attempts to read, write, or act upon resources the user is allowed to know exist. -- Return `404` for attempts to read, write, or act upon resources the user should _not_ know exist. - -### 404 vs. 422 - -- If the resource specified by the URL is **not found**, or the caller should not know it exists, - APIs `MUST` return `404`. -- If the resource specified by the URL is **found**, but a resource referenced within the payload - does not exist (or the caller should not know it exists), APIs `MUST` return `422`. - -### 500 vs. 503 - -- Return `500` for unexpected errors that occur within the service that the client cannot fix. By - definition, a `500` is a bug. -- Return `503` if some dependency of the service is down or unreachable or times out. - -## Resource fields - -Every resource `MUST` include the following fields: - -| Field | Description | Type | -| ------------ | -------------------------------------------------------------------------------- | -------- | -| `attributes` | Details about the resource. | `object` | -| `id` | The unique ID of the resource. Even if the ID is numeric, it `MUST` be a string. | `string` | -| `type` | The _singular_ name of the resource (e.g. `user`). | `string` | - -The only exception is when a resource is being created via `POST` against an API that will generate -the ID. In this case, the `id` field `MUST` be omitted. - -## Standard fields - -When relevant, resources `MUST` use the following field names: - -| Field | Description | Type | -| ----------- | ---------------------------------------------------------------------------------------------- | -------- | -| `createdAt` | The date/time the resource was created, in ISO 8601 format. | `string` | -| `createdBy` | The ID of the user that created the resource. | `string` | -| `deletedAt` | The date/time the resource was deleted, in ISO 8601 format. See [Soft deletes](#soft-deletes). | `string` | -| `deletedBy` | The ID of the user that deleted the resource. | `string` | -| `updatedAt` | The date/time the resource was last updated, in ISO 8601 format. | `string` | -| `updatedBy` | The ID of the user that last updated the resource. | `string` | -| `version` | The current version of the resource. See [Optimistic concurrency](#optimistic-concurrency). | `string` | - -## Verbs - -APIs `MUST` use the standard verb semantics: - -| Verb | Meaning | -| -------- | ----------------------------------------------------------------------------------------------------------------- | -| `DELETE` | Delete a resource. | -| `GET` | Fetch a resource. Safe, idempotent, and never changes state. | -| `PATCH` | Partially update a resource. | -| `POST` | Create a resource. | -| `PUT` | Update a resource with "completely replace" semantics. APIs that support create-or-update `MUST` do so via `PUT`. | - -The only exceptions are [actions](#acting-upon-resources), [advanced queries](#advanced-queries), -[bulk updates](#bulk-updates), and [bulk deletes](#bulk-deletes), which use `POST` without creating -a resource. - -## Creating resources - -- HTTP verb `MUST` be `POST`. -- API path `SHOULD` be like `/api/v1/{resource plural}`. - -**Example** - -``` -POST /api/v1/users -Content-Type: application/json -``` - -```json -{ - "data": { - "attributes": { - "firstName": "Bob", - "lastName": "Smith" - }, - "type": "user" - } -} -``` - -**Success responses** - -Upon success, APIs `SHOULD` return `201` but `MAY` return `202` or `204`. - -| Status | Description | Response | -| ---------------- | ------------------------------------ | ----------------------------------------------------------- | -| `201 Created` | Resource was created. | The latest representation of the resource. | -| `202 Accepted` | Resource is scheduled to be created. | The [job](#jobs) that was scheduled to create the resource. | -| `204 No Content` | Resource was created. | Nothing. | - -## Read many - -- HTTP verb `MUST` be `GET`. -- API path `SHOULD` be like `/api/v1/{resource plural}`. - -**Example** - -``` -GET /api/v1/users -Accept: application/json -``` - -```json -{ - "data": [ - { - "attributes": { - "firstName": "Bob", - "lastName": "Smith" - }, - "id": "62bed180-1f78-45d4-8a56-c996936a2947", - "type": "user" - }, - { - "attributes": { - "firstName": "Alice", - "lastName": "Jones" - }, - "id": "cacba8c1-29fa-4018-8950-acd400ec76b7", - "type": "user" - } - ] -} -``` - -**Success responses** - -Upon success, APIs `MUST` return `200`. - -| Status | Description | Response | -| -------- | ----------------------- | ------------------------ | -| `200 OK` | Request was successful. | The resources requested. | - -**See also:** - -- [Advanced queries](#advanced-queries) -- [Filtering](#filtering) -- [Paging](#paging) -- [Sorting](#sorting) -- [Sparse fieldsets](#sparse-fieldsets) - -## Read one - -- HTTP verb `MUST` be `GET`. -- API path `SHOULD` be like `/api/v1/{resource plural}/{id}`. - -**Example** - -``` -GET /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 -Accept: application/json -``` - -```json -{ - "data": { - "attributes": { - "firstName": "Bob", - "lastName": "Smith" - }, - "id": "62bed180-1f78-45d4-8a56-c996936a2947", - "type": "user" - } -} -``` - -**Success responses** - -Upon success, APIs `MUST` return `200`. - -| Status | Description | Response | -| -------- | ----------------------- | ----------------------- | -| `200 OK` | Request was successful. | The resource requested. | - -## Updating resources - -- HTTP verb `MUST` be `PUT`. -- API path `SHOULD` be like `/api/v1/{resource plural}/{id}`. - -**Example** - -``` -PUT /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 -Accept: application/json -Content-Type: application/json -``` - -```json -{ - "data": { - "attributes": { - "firstName": "Bob", - "lastName": "Smith" - }, - "id": "62bed180-1f78-45d4-8a56-c996936a2947", - "type": "user" - } -} -``` - -**Success responses** - -Upon success, APIs `SHOULD` return `200` — or `201` if the request created the resource — but `MAY` -return `202` or `204`. - -| Status | Description | Response | -| ---------------- | ---------------------------------------- | ----------------------------------------------------------- | -| `200 OK` | The resource was updated. | The latest representation of the resource. | -| `201 Created` | The resource was created. | The latest representation of the resource. | -| `202 Accepted` | The resource is scheduled to be updated. | The [job](#jobs) that was scheduled to update the resource. | -| `204 No Content` | The resource was updated. | Nothing. | - -See also: [Partial updates](#partial-updates) - -## Partial updates - -Services `SHOULD NOT` support partial updates — see [the FAQ](#frequently-asked-questions) for why. -A service that _does_ support them `MUST` use `PATCH` and `MUST` ignore any field that isn't -specified. In a partial update, an absent field means "do not change" and an explicit `null` clears -the value. - -**Example** - -``` -PATCH /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 -Accept: application/json -Content-Type: application/json -``` - -```json -{ - "data": { - "attributes": { - "firstName": "Bobby" - }, - "id": "62bed180-1f78-45d4-8a56-c996936a2947", - "type": "user" - } -} -``` - -**Success responses** - -Upon success, APIs `SHOULD` return `200` but `MAY` return `202` or `204`. - -| Status | Description | Response | -| ---------------- | ---------------------------------------- | ----------------------------------------------------------- | -| `200 OK` | The resource was updated. | The latest representation of the resource. | -| `202 Accepted` | The resource is scheduled to be updated. | The [job](#jobs) that was scheduled to update the resource. | -| `204 No Content` | The resource was updated. | Nothing. | - -## Optimistic concurrency - -- APIs that update resources `SHOULD` implement - [optimistic concurrency](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) to detect - concurrent modifications. Those that do `MUST` do so using a `version` field marked `required` and - not `readOnly`. -- The `version` field `SHOULD` be a simple integer that is incremented after every successful update - but, regardless of what value is used, the field `MUST` be formatted as a string. -- If the value provided does not match the stored version, the API `MUST` respond `409 Conflict`. -- Upon successfully updating the resource, the API `MUST` update the version identifier. - -## Deleting resources - -- HTTP verb `MUST` be `DELETE`. -- API path `SHOULD` be like `/api/v1/{resource plural}/{id}`. - -**Example** - -``` -DELETE /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947 -``` - -**Success responses** - -Upon success, APIs `SHOULD` return `204` but `MAY` return `202`. - -| Status | Description | Response | -| ---------------- | ---------------------------------------- | ----------------------------------------------------------- | -| `202 Accepted` | The resource is scheduled to be deleted. | The [job](#jobs) that was scheduled to delete the resource. | -| `204 No Content` | The resource was deleted. | Nothing. | - -### Soft deletes - -Services `MAY` support "soft deletes" for any number of reasons. Services that do, `SHOULD` follow -the following guidelines: - -- Delete APIs perform a soft delete by default and read-many APIs exclude soft-deletes by default. -- Read-one APIs `SHOULD` return `404` for a soft-deleted resource unless the caller passes - `includeDeleted=true`. -- If callers can request that a delete be "hard", callers pass `permanent=true` query parameter. -- If callers can request that soft-deletes be included in responses, callers pass - `includeDeleted=true` query parameter. -- Soft-deleted resources `SHOULD` carry a `deletedAt` field. - -**Examples** - -``` -DELETE /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947?permanent=true -GET /api/v1/users?includeDeleted=true -``` - -## Acting upon resources - -APIs that don't fit cleanly into the CRUD paradigm can typically be modeled as **actions**. - -- HTTP verb `MUST` be `POST`. -- API path `SHOULD` be like `/api/v1/{resource plural}/{id}/actions/{action}`. - -``` -POST /api/v1/users/62bed180-1f78-45d4-8a56-c996936a2947/actions/send-email -Accept: application/json -Content-Type: application/json -``` - -```json -{ - "subject": "Hello", - "body": "World!" -} -``` - -> Unlike the CRUD APIs, _actions_ impose no restrictions on the shape of the JSON posted. - -**Success responses** - -Upon success, APIs `SHOULD` return `204` but `MAY` return `200` or `202`. - -| Status | Description | Response | -| ---------------- | -------------------------- | ---------------------------------------------------------- | -| `200 OK` | The action was successful. | The latest representation of the resource. | -| `202 Accepted` | The action is scheduled. | The [job](#jobs) that was scheduled to execute the action. | -| `204 No Content` | The action was successful. | Nothing. | - -## Filtering - -- Read-many APIs that allow callers to specify search criteria `MUST` do so via one or more - `filter[{name}]` query parameters where the `name` `SHOULD` be the same as one of the resource - attributes. -- APIs `MAY` support wildcard matching. Those that do `MUST` support `*` for "starts with", "ends - with", and "contains". -- APIs that support filtering by an attribute of an attribute `MAY` use dot-notation (e.g. - `address.city`). - -### Multi-value parameters - -APIs that support specifying multiple values for a query parameter `MUST` do so via a -comma-delimited list. This allows APIs that started with single-value query parameters to evolve to -supporting multiple-values without changing the external-facing contract. When multiple values are -provided, "or" query semantics `MUST` be applied. - -On a parameter that accepts multiple values or a [range](#ranges), a comma that is part of a value -`MUST` be escaped with a backslash — `filter[displayName]=Smith\, Jr` is the single value -`Smith, Jr`. An asterisk `MUST` likewise be escaped wherever the API supports wildcard matching. -Wherever either escape applies, a backslash that is part of a value `MUST` also be escaped as `\\`. -A parameter that takes a single value is never split, so a comma in its value needs no escaping. - -> **Take care when a single-value parameter gains multi-value support.** A caller sending an -> unescaped comma will suddenly be sending two values. That is usually safe, because the evolution -> typically happens on constrained fields whose values cannot contain commas — but confirm it before -> making the change. - -### Ranges - -Query parameters that allow the caller to specify a range of values `MUST` do so using two values, -separated by commas, and using `[` and `]` to represent **inclusive** begin and end, and `(` and `)` -to represent **exclusive** begin and end. An asterisk `*` `MAY` be used to represent no limit. - -Ranges are also how "less than", "greater than", "less than or equal to", and "greater than or equal -to" are implemented. - -### Examples - -| Example | Description | -| --------------------------------------------------------------- | ----------------------------------------------- | -| `filter[lastName]=Smith` | Users whose last name is 'Smith'. | -| `filter[lastName]=S*` | Users whose last name begins with 'S'. | -| `filter[lastName]=*th` | Users whose last name ends with 'th'. | -| `filter[lastName]=*mi*` | Users whose last name contains 'mi'. | -| `filter[lastName]=Smith,Jones` | Users whose last name is 'Smith' or 'Jones'. | -| `filter[failedLoginCount]=[3,*)` | Users who have 3 or more failed login attempts. | -| `filter[birthDate]=(*,2000-01-01T00:00:00Z)` | Users born before the year 2000. | -| `filter[birthDate]=[2000-01-01T00:00:00Z,2001-01-01T00:00:00Z)` | Users born in the year 2000. | - -See also [Advanced Queries](#advanced-queries). - -## Paging - -APIs `SHOULD` support paging and those that do `MUST` support either **offset/limit** style paging -or **cursor** style paging. - -### Offset/limit style paging - -APIs that implement offset/limit style paging do so via the following query parameters: - -| Parameter | Description | -| -------------- | ------------------------------------- | -| `page[number]` | The page number to return, one-based. | -| `page[size]` | The number of resources to return. | - -Paging metadata `MUST` be populated within the `meta` field of responses as follows: - -| Field | Description | -| ------------ | --------------------------------------------------- | -| `pageCount` | The total number of pages. | -| `pageNumber` | The current page number. | -| `pageSize` | The current page size. | -| `totalCount` | The total number of resources matching the request. | - -**Example** - -``` -GET /api/v1/users?page[number]=1&page[size]=2 -Accept: application/json -``` - -```json -{ - "data": [ - { - "attributes": { - "firstName": "Bob", - "lastName": "Smith" - }, - "id": "62bed180-1f78-45d4-8a56-c996936a2947", - "type": "user" - }, - { - "attributes": { - "firstName": "Alice", - "lastName": "Jones" - }, - "id": "cacba8c1-29fa-4018-8950-acd400ec76b7", - "type": "user" - } - ], - "meta": { - "pageCount": 5, - "pageNumber": 1, - "pageSize": 2, - "totalCount": 9 - } -} -``` - -### Cursor-style paging - -APIs that implement cursor-style paging do so via the following query parameters: - -| Parameter | Description | -| ------------- | ------------------------------------------------------------------ | -| `page[size]` | The number of resources to return. | -| `page[after]` | An opaque cursor. Returns the resources that follow that position. | - -A cursor `MUST` be treated as opaque. Callers `MUST NOT` construct, parse, or modify one, and a -service `MAY` change its encoding at any time without a new API version. - -Paging metadata `MUST` be populated within the `meta` field of responses as follows: - -| Field | Description | -| ---------- | -------------------------------------------------------------- | -| `pageSize` | The current page size. | -| `hasMore` | Whether more resources follow this page. | -| `cursor` | The cursor to pass as `page[after]` to retrieve the next page. | - -**Example** - -``` -GET /api/v1/users?page[size]=2&page[after]=dXNlcnM6NjJiZWQxODA -Accept: application/json -``` - -```json -{ - "data": [ - { - "attributes": { - "firstName": "Bob", - "lastName": "Smith" - }, - "id": "62bed180-1f78-45d4-8a56-c996936a2947", - "type": "user" - }, - { - "attributes": { - "firstName": "Alice", - "lastName": "Jones" - }, - "id": "cacba8c1-29fa-4018-8950-acd400ec76b7", - "type": "user" - } - ], - "meta": { - "cursor": "dXNlcnM6Y2FjYmE4YzEtMjlmYQ", - "hasMore": true, - "pageSize": 2 - } -} -``` - -The final page `MUST` return `hasMore` as `false` and `MUST` omit `cursor`. - -## Sorting - -APIs that support sorting `MUST` do so using a single query parameter named `sort` whose value is a -comma-delimited list of field names to sort by. Any field preceded by a hyphen `MUST` be interpreted -as descending. - -**Examples** - -| Example | Description | -| ------------------------- | ---------------------------------------------------- | -| `sort=lastName` | Sort by the user's last name ascending. | -| `sort=-lastName` | Sort by the user's last name descending. | -| `sort=lastName,firstName` | Sort by the user's last name and then by first name. | - -## Sparse fieldsets - -APIs `MAY` allow callers to specify which fields to return. Those that do `MUST` accept a `fields` -query parameter whose value is a comma-delimited list of the fields to include. `id` and `type` are -always returned. - -**Example** - -``` -GET /api/v1/users?fields=firstName,lastName -``` - -## Advanced queries - -The `filter` query parameters cover simple predicates, but they cannot express arbitrary `AND` and -`OR` combinations, and a long expression will exceed the practical limit on URL length. APIs that -need richer queries `MUST` expose a **search** operation that carries the expression in the request -body. - -- HTTP verb `MUST` be `POST`. -- API path `MUST` be like `/api/v1/{resource plural}:search`. - -The expression is written in [JSON Logic](https://jsonlogic.com/) and placed in a top-level `filter` -field. JSON Logic is adopted for its notation only. Its full operator set includes control flow, -iteration, and arithmetic, none of which belong in a query, so only the following operators are -supported: - -| Operator | Meaning | -| -------------------- | --------------------------------------------------- | -| `var` | Reference a field of the resource. | -| `==`, `!=` | Equal, not equal. | -| `>`, `>=`, `<`, `<=` | Numeric and date comparison. | -| `in` | Membership in a list, or a substring match. | -| `and`, `or` | Boolean composition, each taking two or more terms. | -| `!` | Negation. | - -A request that uses any other operator, or references a field the API does not support, `MUST` be -rejected with `422`. - -**Example** - -Find active or invited users whose KDF iterations are below the current minimum: - -``` -POST /api/v1/users:search?page[size]=50&sort=-createdAt -Accept: application/json -Content-Type: application/json -``` - -```json -{ - "filter": { - "and": [ - { "<": [{ "var": "kdfIterations" }, 600000] }, - { "in": [{ "var": "status" }, ["ACTIVE", "INVITED"]] } - ] - } -} -``` - -The response `MUST` be identical in shape to the equivalent [read-many](#read-many) request. - -[Learn more about JSON Logic](https://jsonlogic.com/) - -**Success responses** - -Upon success, APIs `MUST` return `200`. - -| Status | Description | Response | -| -------- | -------------------------- | ----------------------------------------- | -| `200 OK` | The search was successful. | The matching resources, paged and sorted. | - -## Bulk updates - -Bulk updates apply the same change to every resource matching a filter. - -- HTTP verb `MUST` be `POST`. -- API path `MUST` be like `/api/v1/{resource plural}:bulk-update`. -- `filter` `MUST` be present and use the [advanced query](#advanced-queries) expression language. -- An `update` field contains the fields being changed. - -**Example** - -``` -POST /api/v1/users:bulk-update -Accept: application/json -Content-Type: application/json -``` - -```json -{ - "filter": { - "in": [{ "var": "status" }, ["INVITED", "PENDING"]] - }, - "update": { - "status": "DISABLED" - } -} -``` - -**Success responses** - -Upon success, APIs `SHOULD` return `202` but `MAY` return `200`. - -| Status | Description | Response | -| -------------- | ------------------------------------------ | ------------------------------------------------------------ | -| `200 OK` | The resources were updated. | `meta.affectedCount`, the number of resources updated. | -| `202 Accepted` | The resources are scheduled to be updated. | The [job](#jobs) that was scheduled to update the resources. | - -## Bulk deletes - -Bulk deletes delete every resource matching a filter. - -- HTTP verb `MUST` be `POST`. -- API path `MUST` be like `/api/v1/{resource plural}:bulk-delete`. -- `filter` `MUST` be present and use the [advanced query](#advanced-queries) expression language. -- On a service that supports [soft deletes](#soft-deletes), a bulk delete `MUST` soft-delete. - -**Example** - -``` -POST /api/v1/users:bulk-delete -Accept: application/json -Content-Type: application/json -``` - -```json -{ - "filter": { - "and": [ - { "==": [{ "var": "status" }, "INVITED"] }, - { "<": [{ "var": "invitedAt" }, "2026-06-01T00:00:00Z"] } - ] - } -} -``` - -**Success responses** - -Upon success, APIs `SHOULD` return `202` but `MAY` return `200`. - -| Status | Description | Response | -| -------------- | ------------------------------------------ | ------------------------------------------------------------ | -| `200 OK` | The resources were deleted. | `meta.affectedCount`, the number of resources deleted. | -| `202 Accepted` | The resources are scheduled to be deleted. | The [job](#jobs) that was scheduled to delete the resources. | - -## Request validation - -Requests `MUST` be exhaustively validated before attempting to process the request and `SHOULD` -return all errors at once (not stop after the first error is encountered). Exhaustive validation -includes the following: - -- Every required field, parameter, and header is present, not set to null, and not an empty string. -- The data type of every field, parameter, and header is the correct data type. In the case of - parameters and headers, since those always originate as strings, it also means "can be converted - to the declared type". -- Field names specified by `sort` or `fields` parameters are valid field names. -- Values provided for constrained fields are among the enumerated values. -- Values provided for fields that restrict the minimum value, the maximum value, and/or the maximum - length are within those constraints. -- Identifiers that refer to other resources, whether managed by the service or some other service, - are valid. - -> With some enhancements, developers should get all of the above "for free" from the -> `Bitwarden.Server.Sdk` which will **guarantee** that no request reaches developer code that -> doesn't conform to the declared request model. - -## Errors - -Errors `MUST` be returned as an array of error objects within a top-level `errors` field. Each error -populates the following fields, except that at most one `source` member applies to any one error: - -| Field | Description | Type | -| ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------- | -| `code` | A stable, machine-readable error code. | `string` | -| `detail` | A human-readable explanation specific to this occurrence. May be localized. | `string` | -| `id` | A unique identifier for this particular occurrence of the problem. | `string` | -| `source` | A reference to the primary source of the error. | `object` | -| `source.header` | The name of the request header that caused the error. | `string` | -| `source.parameter` | The query parameter that caused the error. | `string` | -| `source.pointer` | A [JSON Pointer](https://www.rfc-editor.org/info/rfc6901/) to the field in error (e.g. `/data/attributes/title`). | `string` | -| `status` | The HTTP status code applicable to this problem. | `string` | -| `title` | A short, human-readable summary of the problem that doesn't change from occurrence to occurrence. | `string` | - -**Example** - -```json -{ - "errors": [ - { - "id": "9f3c1e2a-7d40-4c8b-9b17-2f5a1c6e83d1", - "status": "422", - "code": "resource-not-found", - "title": "Referenced resource does not exist", - "detail": "'e3d2eb3e-755c-41cd-86f0-0e0649043ef6' is not a group in this organization.", - "source": { - "pointer": "/data/attributes/groups/0" - } - } - ] -} -``` - -> A `500` response `MUST NOT` include any detail about the failure. `title` and `detail` `MUST` be -> generic, and `source` `MUST` be omitted. Exception messages, stack traces, type names, connection -> strings, and dependency identities are all disclosure risks and belong in traces and logs, which -> are not reachable by the caller. The `id` field is how a caller and an operator correlate a report -> with the logged detail. - -## Jobs - -APIs that accept work to be completed asynchronously return `202` along with a **job** — a resource -representing the accepted work and its progress. A formal standard for jobs is forthcoming. - -## Deprecation - -Deprecation announces that an API should no longer be called, tells callers what they _should_ be -calling, instead, and, ideally, how much time they have to migrate. - -- Every deprecated API `MUST` be marked `deprecated: true` in the service's OpenAPI spec and the - `description` field `MUST` name the replacement. -- Every response from a deprecated API `MUST` include the `Deprecation` and `Sunset` headers and - `SHOULD` include a `Link` header naming the replacement. - -> Learn more about the [Deprecation](https://www.rfc-editor.org/info/rfc9745/) and -> [Sunset](https://www.rfc-editor.org/info/rfc8594/) headers. - -**Example** - -```http -Deprecation: @1688169599 -Sunset: Sun, 30 Jun 2024 23:59:59 GMT -Link: ; rel="successor-version" -``` - -| Header | Description | -| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `Deprecation` | When the operation became deprecated, as an HTTP structured field date — `@` followed by seconds since the Unix epoch. | -| `Sunset` | When it will stop working, as an HTTP date. It `MUST` be a real date, and it `MUST NOT` pass without either removal or a published extension. | -| `Link` | Points at the replacement. | - -## Divergences from JSON:API - -- Request and responses use the `application/json` media type, not `application/vnd.api+json`. -- We do not support [compound documents](https://jsonapi.org/format/#document-compound-documents) or - the related `include` query parameter. -- Paged responses are not required to include `links`. -- For sparse fieldsets, callers specify the fields to include via a `fields` query parameter. We do - not support the `fields[{type}]` form. -- Unrecognized query parameters are ignored rather than rejected with `400`, and the result-shaping - subset is rejected with `422`. See - [Unrecognized fields, query parameters, and headers](#unrecognized-fields-query-parameters-and-headers). -- Implementation-specific query parameters are not required to contain a non-`a-z` character. -- Resources are updated with `PUT` and complete-replace semantics rather than `PATCH`. `PATCH`, - where a service supports it, follows the specification's partial-update semantics. -- We do not support `relationships` objects. A reference to another resource is an `attributes` - member holding its ID. - -## Frequently asked questions - -> Why JSON:API? - -We believe an established standard, with thoughtful answers to every API question, will be more -robust than any standard we might invent ourselves. It is widely adopted among some of the largest -SaaS vendors in the industry including [Datadog](https://docs.datadoghq.com/api/latest). - -We adopt the standard selectively, however, to get most of the benefits of JSON:API without the -burden of full conformance. - -> Why is there an envelope? Can't the API just return the object? - -1. The envelope gives pagination, counts, and other response metadata somewhere to live that is not - mixed in with the resource itself. -1. It is a serialization concern, not a programming model concern. Handlers take and return plain - model classes; how those serialize is the framework's business. No application code should ever - see `data` or `attributes`. - -> Why are service-to-service APIs versioned? Public APIs aren't. - -We feel strongly that service-to-service APIs should be formally versioned. Without formal -versioning, every change must be additive, which means the shape can never change and every new -field is optional. Contracts constrained like that get weaker over time, and what we _want_ to -express eventually cannot be expressed, because we have committed ourselves to "additive changes -only". This rules out adopting the existing public API conventions, which are expressly unversioned. - -> Why do I have to specify the ID in the path _and_ the JSON body? - -1. So that the JSON is comprehensive and self-describing. -1. Establishing just one rule to "always include it" is easier to remember than multiple rules for - when it is required and when it is not required. - -> Why do I have to specify the type? You can infer that from the URL path. - -1. So that the JSON is comprehensive and self-describing. -1. Establishing just one rule to "always include it" is easier to remember than multiple rules for - when it is required and when it is not required. - -> Why should empty strings be treated like `null`? - -A system where `""` and `null` mean two different things requires every caller, every service, and -every database column to agree on the distinction. This can be a challenge to preserve as data gets -serialized to and from various representations across various languages and, in our experience, this -is one class of bug that can be entirely eliminated by just treating them the same. - -> Why shouldn't we support partial updates? - -1. `PUT` with completely-replace semantics is simpler to implement and far more common. -1. UI teams tend to prefer read-modify-write semantics which works well with `PUT`. -1. Services that support `PATCH` frequently have to _also_ support `PUT` which creates two endpoints - to do the same thing. -1. It is hard to tell the difference between "null this field out" vs. "do not update this field". - -To be clear, teams _can_ support partial updates. We just think you will be better served by just -sticking to simple updates. - -> How do I support just adding or removing an element from a collection? - -You `MAY` implement the [JSON Patch](https://www.rfc-editor.org/info/rfc6902/) specification, which -was designed for exactly this. However, we think an [action](#acting-upon-resources) works just as -well and is simpler: - -``` -POST /api/v1/groups/62bed180-1f78-45d4-8a56-c996936a2947/actions/add-collection -``` - -> Why are we standardizing on JSON Logic instead of OData? - -1. OData's `$filter` is a string expression language, so both building and parsing it require string - handling where JSON is already right there. -1. Composing a filter by concatenating strings means quoting and escaping, which is how injection - bugs happen. Composing a JSON object does not. -1. OData brings a great deal more than filtering — metadata documents, `$expand`, batch, its own - conventions — and we only want the filtering. From cc765ac1055b54c0df24fcbbea0ac9bed7279da0 Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Thu, 24 Sep 2026 13:47:25 -0400 Subject: [PATCH 22/23] Generalize the ADR to simply propose that we should establish standards --- .../0036-service-to-service-api-standards.md | 223 ++++++++---------- 1 file changed, 93 insertions(+), 130 deletions(-) diff --git a/docs/architecture/adr/0036-service-to-service-api-standards.md b/docs/architecture/adr/0036-service-to-service-api-standards.md index 993f2a003..4a40b3103 100644 --- a/docs/architecture/adr/0036-service-to-service-api-standards.md +++ b/docs/architecture/adr/0036-service-to-service-api-standards.md @@ -9,7 +9,7 @@ tags: [server, server-sdk] -{/* cspell:ignore Appibase Catalio */} +{/* cspell:ignore fieldsets */} ## Notation @@ -22,161 +22,124 @@ API is out of scope and is not changing. ## Context and problem statement -Service-to-service APIs are new. As server functionality is decomposed into independently deployable -services, a service calling another service needs a wire contract, and no documented standard -exists. - -One important distinction to keep in mind as we consider various options is that the APIs we are -talking about are _service-to-service_ APIs. We control both the service that declares them and -every client that consumes them. While we _do_ still want to adhere to the principle of "no breaking -changes", we also do not want that to result in an ever-growing number of optional fields that -ultimately make these contracts less clear. - -In the same way we [refactor our code mercilessly](https://wiki.c2.com/?RefactorMercilessly), we -want the liberty to be able to do the same with our APIs. Especially when they are still under -active development and rapidly evolving. - -Said differently, we do not want the rules that restrict how public-facing APIs may evolve to -restrict how, or the pace at which, we evolve service-to-service APIs. +[ADR-0035](./0035-service-oriented-architecture.md) establishes that services communicate through +published APIs and that service-to-service APIs conform to a forthcoming set of standards. As more +server functionality is decomposed into independently deployable services, the number of +service-to-service APIs, and the number of teams building and consuming them, will grow. No +documented standard for them exists today. + +Without one, every team answers the same questions independently, and a consumer calling five +services learns five error formats, five pagination schemes, and five ways to filter. The +conventions that exist today live in precedent rather than in writing, so they cannot be cited in +review, generated from, or enforced. + +The existing conventions are also a poor fit to adopt as-is. They were shaped by constraints of APIs +whose callers we do not control, such as additive-only evolution and no formal versioning. With +service-to-service APIs we control both the service that declares an API and every client that +consumes it. We still want to avoid breaking changes, but we do not want that goal to produce an +ever-growing number of optional fields that make contracts less clear over time. The standards we +need are therefore likely to look very different from the conventions of our existing APIs. + +A well-defined API is much more than an HTTP verb, a path, and a JSON payload. It spells out exactly +how it is called and what the caller can expect in return, on both the happy path and every other +path. To be useful, the standards have to be comprehensive. At a minimum, they need to address: + +- **Authentication and authorization:** how a caller proves its identity, how a service authorizes + an operation, and how the current organization travels with a request. +- **API descriptions:** how APIs are documented in OpenAPI, including stable operation identifiers + for generated clients. +- **API paths:** how paths are structured and named. +- **Breaking changes and versioning:** what constitutes a breaking change and how an API evolves + when one is unavoidable. +- **Unrecognized fields, query parameters, and headers:** how a service responds to input it does + not understand, including after a rollback. +- **JSON:** the shape of request and response documents and how values are serialized. +- **Naming conventions:** casing and naming of resources, fields, and parameters. +- **Content negotiation:** which media types are accepted and returned, and what happens when a + requested type cannot be honored. +- **Standard responses:** which status codes are returned and when, including the commonly confused + cases (400 vs. 422, 403 vs. 404, 404 vs. 422, 500 vs. 503). +- **Resource fields and standard fields:** the fields every resource carries and the names used for + common fields such as creation and modification timestamps. +- **Verbs:** which HTTP verbs are used for which operations. +- **Creating resources:** request and response shapes for creation. +- **Reading resources:** reading one resource and reading many. +- **Updating resources:** full replacement and partial updates. +- **Optimistic concurrency:** how conflicting concurrent updates are detected and rejected. +- **Deleting resources:** hard deletes and soft deletes. +- **Acting upon resources:** operations that do not fit cleanly into create, read, update, and + delete. +- **Filtering:** a consistent filter grammar, including multi-value parameters and ranges. +- **Paging:** offset/limit and cursor-style paging. +- **Sorting:** how callers request an order. +- **Sparse fieldsets:** how callers request only the fields they need. +- **Advanced queries:** how richer queries are expressed when simple filters are not enough. +- **Bulk operations:** updating and deleting many resources in one request. +- **Request validation:** what is validated, when, and how failures are reported. +- **Errors:** a single error format that is both human-readable and machine-actionable. +- **Jobs:** how asynchronous work is accepted and its progress reported. +- **Deprecation:** how an API or version is marked for removal and communicated to consumers. ## Considered options -- **Adopt the existing public API conventions:** apply the same shape and the same no-versioning - posture to service-to-service APIs. -- **Keep the existing JSON shape and add versioning:** retain today's conventions, introduce path - versioning. -- **Adopt [JSON:API](https://jsonapi.org/) strictly:** implement the specification in full, - including its media type and `relationships` objects, and add versioning. -- **Adopt [JSON:API](https://jsonapi.org/) selectively:** implement most of the specification but - don't worry about 100% compliance, and add versioning. - -### Adopt the existing public API conventions - -**Pros** - -- One shape across every Bitwarden API, public and service-to-service. -- Nothing new for engineers to learn, and no migration for code already written this way. - -**Cons** - -- Carries over the no-versioning posture, which forces additive-only evolution on APIs whose callers - we control. -- Its envelope is undocumented and answers only pagination. `object` and `data` exist only as - properties on a response model class, so anything else the response needs to carry has nowhere - defined to go. -- The convention is not written down. A standard that exists only as precedent cannot be cited in - review, generated from, or enforced. +- **No documented standards:** each team designs its service-to-service APIs as it sees fit, + following existing precedent where it exists. +- **Documented standards:** publish a comprehensive set of standards that every service-to-service + API conforms to. -### Keep the existing JSON shape and add versioning +### No documented standards **Pros** -- Solves the versioning problem. -- Smallest change from current practice. +- No up-front investment in writing standards. +- Teams are free to design each API around its immediate needs. **Cons** -- Leaves us maintaining a proprietary standard. The questions an API standard answers (document - shape, metadata, errors, pagination, filtering, sparse responses, and so on) are not - Bitwarden-specific. Where a public specification already proposes answers to these common - questions, our time and energy is better spent adopting it. -- The remaining gaps have to be filled by us, individually, as each one is discovered. +- Every team answers the same questions independently, and the answers diverge. +- Consumers must learn each service's conventions separately. +- Precedent cannot be cited in review, generated from, or enforced. +- Conventions shaped by APIs whose callers we do not control carry over to APIs whose callers we do. -### Adopt JSON:API strictly +### Documented standards **Pros** -- It is a public specification, not house style. Registered as a media type in 2013, now at v1.1, - revised in the open, with implementations across major languages. -- It answers almost every question an API standard faces, not just the obvious ones: document shape, - resource identity, pagination, filtering, sorting, sparse responses, compound documents, metadata, - and errors. -- Responses standardize where to find the resource `type`, `id`, and `attributes`, which enables - generic tooling over the entire API surface. -- Error responses are richly described to facilitate both human-readable details and a - [JSON Pointer](https://www.rfc-editor.org/info/rfc6901/) that identifies the exact member of the - request that failed (which, again, enables generic tooling). +- One set of answers across every service-to-service API, so consumers learn them once. +- Standards can be cited in review, relied upon by generated clients and shared tooling, and applied + consistently by engineers and AI agents alike. +- Standards can be designed for APIs whose callers we control, rather than inherited from APIs whose + callers we do not. **Cons** -- In code, model objects are typically "flat"; on the JSON:API wire, they are nested inside `data` - and `attributes` envelopes. A good framework will mask these envelopes from the actual classes - developers work with, but a direct projection will result in request and response models that feel - awkward. -- Parts of the specification are a real implementation burden for little return (e.g. - `relationships` objects, with their resource linkage and related-resource links). -- Every API must advertise that it accepts and returns the `application/vnd.api+json` media type, - which inevitably creates problems for clients that expect to send and receive `application/json`. - Advertising `application/json` while still accepting `application/vnd.api+json` is only available - to us because we are not claiming full conformance. - -### Adopt JSON:API selectively - -**Pros** - -- Almost all the pros of [Adopt JSON:API strictly](#adopt-jsonapi-strictly) with just one con. - -**Cons** - -- Our APIs will walk like and talk like JSON:API but are not _quite_ JSON:API, which could be - surprising to a human. It is hard to imagine any negative impact to the machine that consumes - them. +- Writing and maintaining comprehensive standards takes time and effort. +- Service-to-service APIs will likely look different from our existing APIs. ## Decision outcome -Chosen option: **Adopt JSON:API selectively with versioning**. - -- We believe an established standard, with thoughtful answers to every API question, will be more - robust than any standard we might invent ourselves. It is widely adopted among some of the largest - SaaS vendors in the industry including [ART19](https://marketplace.apilayer.com/art19_content-api) - (an Amazon company) and [Datadog](https://docs.datadoghq.com/api/latest). -- We feel strongly that service-to-service APIs should be formally versioned. Without formal - versioning, a change to an existing contract has to be either additive or coordinated with every - consumer's release cycle, which in practice means the shape rarely changes and every new field is - optional. Contracts constrained like that get weaker over time, and what we _want_ to express - eventually cannot be expressed, because we have committed ourselves to "additive changes only". - This rules out adopting the existing public API conventions, which are expressly unversioned. -- We adopt the standard selectively to get most of the benefits of JSON:API without the burden of - full conformance. - -The specific standards, including where we deviate from JSON:API, are published here: -[Service-to-service API standards](../server/service-to-service-api-standards.md). That page is the -living reference: its rules evolve by pull request without superseding this decision, and this ADR -is superseded only if the model itself changes. +Chosen option: **Documented standards**. + +Service-to-service APIs `MUST` conform to a comprehensive, documented set of standards covering, at +a minimum, the topics listed above. The standards will be published separately as a living +reference: their rules evolve by pull request without superseding this decision, and this ADR is +superseded only if the decision to have documented standards itself changes. ### Positive consequences -- One document shape across every service-to-service API, so a consumer calling five services learns - one error format, one pagination scheme, and one filter grammar. -- Versioning gives a contract a way to change shape. A breaking change becomes a new version with a - migration, instead of more optional parameters. -- The envelope gives pagination state and other response metadata somewhere to live that is not - mixed into the resource. -- Decisions we would otherwise have to make are already made, in public, by a specification that has - been maturing since 2013. +- Consumers learn one error format, one pagination scheme, one filter grammar, and so on, across + every service-to-service API. +- Decisions are made once, in writing, rather than repeatedly and inconsistently by each team. +- Consistent contracts enable generated clients and generic tooling over the entire + service-to-service API surface. ### Negative consequences -- Service-to-service APIs "look different" from public APIs. -- Additional work is required to ensure the "envelopes" are largely transparent to developers when - working with request and response models in code. -- Partial conformance invites any deviation from the specification to be argued as allowed. We will - mitigate this by documenting the specific exceptions; we will comply with all other aspects of the - specification. +- Service-to-service APIs will "look different" from existing APIs. +- The standards require ongoing ownership and maintenance. ### Plan -- Build out the framework that makes the JSON:API "envelope tax" disappear from daily development. -- Publish a standard for authentication, authorization, and how the current organization travels - with a request. -- Publish a standard for jobs, the resource a `202 Accepted` returns. - -## References - -- [Appibase Docs](https://appibase.com/docs) -- [ART19 Content API](https://marketplace.apilayer.com/art19_content-api) -- [Bitwarden Public API](https://bitwarden.com/help/public-api) -- [JSON:API — Active Ants ShopAPI v3](https://developer.activeants.com/docs/shopapi/v3/general-concepts/jsonapi) -- [JSON:API Overview — Catalio](https://catalio.ai/docs/json-api-overview) -- [JSON:API specification](https://jsonapi.org/) -- [Public API](https://contributing.bitwarden.com/getting-started/server/public-api) +- Publish the service-to-service API standards, addressing every topic listed above. +- Build the framework support in `Bitwarden.Server.Sdk` that makes conforming to the standards the + path of least resistance. From bad08b9690772fefdf012f34ea5c8614be61931c Mon Sep 17 00:00:00 2001 From: Mike Gorman Date: Thu, 24 Sep 2026 13:50:27 -0400 Subject: [PATCH 23/23] Update custom-words.txt --- custom-words.txt | 1 + docs/architecture/adr/0036-service-to-service-api-standards.md | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/custom-words.txt b/custom-words.txt index 41261987d..65429e19b 100644 --- a/custom-words.txt +++ b/custom-words.txt @@ -24,6 +24,7 @@ dockerized dotfile downcasting F-Droid +fieldsets frontmatter Gitter HKDF diff --git a/docs/architecture/adr/0036-service-to-service-api-standards.md b/docs/architecture/adr/0036-service-to-service-api-standards.md index 4a40b3103..c1a18603b 100644 --- a/docs/architecture/adr/0036-service-to-service-api-standards.md +++ b/docs/architecture/adr/0036-service-to-service-api-standards.md @@ -9,8 +9,6 @@ tags: [server, server-sdk] -{/* cspell:ignore fieldsets */} - ## Notation This ADR uses [RFC 2119](https://www.rfc-editor.org/info/rfc2119/) keywords (`MUST`, `MUST NOT`,