Skip to content

docs: Add ADR 0040 - API standards for request, response, and error payloads - #881

Draft
mike-gorman-bitwarden wants to merge 2 commits into
mainfrom
docs/adr-0040-api-payload-standards
Draft

mike-gorman-bitwarden wants to merge 2 commits into
mainfrom
docs/adr-0040-api-payload-standards

Conversation

@mike-gorman-bitwarden

@mike-gorman-bitwarden mike-gorman-bitwarden commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

🎟️ Tracking

Follow-up to ADR-0036 (#865).

📔 Objective

Adds ADR-0040, "API standards for request, response, and error payloads", with status Proposed. Sets what every request and response shares: the payload envelope (data, meta, and id/type/attributes on every resource), standard fields, JSON value rules (dates, enumerations, whitespace, empty strings and null), naming, content negotiation, method semantics, status codes, exhaustive request validation, and the error format, with the recorded departure from RFC 9457.

…ayloads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Deploying contributing-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6700cfa
Status: ✅  Deploy successful!
Preview URL: https://a83590bd.contributing-docs.pages.dev
Branch Preview URL: https://docs-adr-0040-api-payload-st.contributing-docs.pages.dev

View logs


- **An array of error objects** in a top-level `errors` field.
- **[RFC 9457](https://www.rfc-editor.org/info/rfc9457/) Problem Details,** as published.
- **Problem Details with an `errors` extension member,** or ASP.NET Core's

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have already started adopting problem details. Here

@mike-gorman-bitwarden mike-gorman-bitwarden Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, good to know. That's Bit.HttpExtensions, used for Admin Console validation errors today. Problem Details was one of the options considered (lines 41–43), and lines 238–246 explain why the ADR chose error objects instead: one shape for one or many failures, source for parameters and headers, and per-item failures in best-effort bulk responses. These standards apply only to new APIs in new services, so existing responses like that one don't change.

**Errors**

- **An array of error objects** in a top-level `errors` field.
- **[RFC 9457](https://www.rfc-editor.org/info/rfc9457/) Problem Details,** as published.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This RFC is still proposed, not accepted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RFC 9457 is published (July 2023, replacing RFC 7807). "Proposed Standard" is its IETF maturity level, the same as most RFCs in use, such as RFC 7396 for merge patch. Either way, the ADR doesn't adopt it; it's listed as a considered option.

Comment on lines +194 to +228
### Errors

Errors `MUST` be returned as an array of error objects in a top-level `errors` field. Each error
has:

| Field | Description |
| ------------------ | ----------------------------------------------------------------------- |
| `code` | A stable, machine-readable error code. |
| `detail` | A human-readable explanation of this occurrence. May be localized. |
| `id` | A unique identifier for this occurrence. |
| `meta.resource` | In a bulk operation, the `type` and `id` of the resource that failed. |
| `source.header` | The request header that caused the error. |
| `source.parameter` | The query parameter that caused the error. |
| `source.pointer` | A JSON Pointer to the field in error, such as `/data/attributes/title`. |
| `status` | The HTTP status code that applies, as a string. |
| `title` | A short summary that does not change from occurrence to occurrence. |

At most one `source` member applies to any one error.

```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"
}
}
]
}
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should really be https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.problemdetails?view=aspnetcore-10.0 which is based on https://www.rfc-editor.org/info/rfc9457.

We get a lot of value leaning into the asp.net standard and it's quite painful to roll your own validation in asp.net.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation stays ASP.NET's: annotations, model binding, and .NET 10's minimal-API validation all still run. Only the response format changes, and that's three standard hooks (InvalidModelStateResponseFactory, an IExceptionHandler, and UseStatusCodePages). Done once in Bitwarden.Server.Sdk, that's a few hundred lines, and no service writes any of it. These standards apply only to new APIs in new services, so nothing existing has to change. Lines 238–246 give the reasons for error objects over Problem Details. Happy to talk it through if those don't hold up for you.

Comment on lines +149 to +168
### Status codes

Status codes follow [RFC 9110](https://www.rfc-editor.org/info/rfc9110/). The following are possible
for every API, and APIs `SHOULD NOT` document them individually:

| Status | Meaning |
| ------ | -------------------------------------------------------------------------------- |
| `400` | The request is malformed and cannot be parsed, such as invalid JSON. |
| `401` | The caller is not authenticated. |
| `403` | The caller is not authorized to invoke the API. |
| `404` | The resource named in the path does not exist, or the caller may not know of it. |
| `405` | The HTTP method is not allowed. |
| `406` | The API cannot return the requested media type. |
| `409` | The update conflicts with another update. |
| `415` | The API does not accept the request's media type. |
| `422` | The request is invalid in a way the caller can fix. |
| `429` | The caller has made too many requests. |
| `500` | An unexpected error the caller cannot fix. By definition, a bug. |
| `501` | The API is stubbed out and not yet implemented. |
| `503` | A dependency is unavailable or timed out. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our clients are notoriously terrible at handling error codes and we need to be very mindful of certain error codes ever routing into client handling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These standards only cover one service calling another, so these codes go to a service, never to a client. A service that calls another decides what its own callers see, and it shouldn't pass a downstream 403 straight through to a client.


- **Treat an empty string, an absent field, and `null` alike,** and strip surrounding whitespace.
- **Preserve every string exactly,** and distinguish an empty string from `null`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the empty string the same as null? This seems to say both, then the other.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, with one exception. Lines 47 and 48 were two opposing options, and the first was chosen, but the list didn't say so. I've marked the chosen option in each group to make that clearer. The rules are at lines 113–114: an empty string is treated as absent, and absent is treated as null. The exception is a partial update, where absent means "leave unchanged" (ADR-0044).

"lastName": "Smith"
},
"id": "62bed180-1f78-45d4-8a56-c996936a2947",
"type": "user"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is type globally unique, unique per service?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is unique per service and, in practice, almost always globally unique.


### Standard fields

When relevant, resources `MUST` use these names: `createdAt`, `createdBy`, `updatedAt`, `updatedBy`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest using CreationDate, RevisionDate, and DeletedDate instead of createdAt, updatedAt, and deletedAt. This would align with the existing fields in the database.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These standards apply only to new APIs in new services whose database does not yet exist. Having said that, I suppose your suggested names are as good as any others but I feel like the names proposed are a bit more fluent as in 'created at [such-n-such time]' and 'created by [such-n-such user]'. Will leave this comment open to see if others have an opinion.

@rkac-bw

rkac-bw commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

From the database side, four points:

  1. Overload and retry semantics. The status table has 429 (caller made too many requests) and 503 (a dependency is unavailable or timed out), but no case for a service shedding load itself, and no Retry-After. Suggest: 429 and 503 SHOULD carry Retry-After; a service that rejects work to protect its own store (for example, approaching its database worker limit) returns 503 with Retry-After; and the ADR states which statuses a caller may retry, and that it honours Retry-After. Without this, every service client invents its own retry rules, and stacked retries multiply load on the database exactly when it is struggling.
  2. A timed-out write may have committed. 503 covers "timed out", but after a database timeout the write may still have committed. A caller that retries can duplicate it. That needs idempotency (see the comment on ADR-0042) or a rule that writes which time out are not retried blindly.
  3. Validating identifiers owned by another service ("valid … owned by this service or another") is the application-level replacement for a cross-service foreign key. Worth saying that it should be batched where a request carries many IDs, that it is bounded by the request deadline, and that it is a check at write time only: the referenced resource can still be deleted afterwards, so the owning service still needs a delete handler or cleanup.
  4. Dates should not be used for concurrency. Today's Cipher conflict check compares RevisionDate with a one-second tolerance, because precision does not survive the round trip (SQL Server datetime2(7) stores 100 ns), and it skips the check when the caller omits the date. As a result, two conflicting edits within a second are not detected. Suggest ADR-0040 says dates are informational and conflict detection uses version (ADR-0044), or else that fractional seconds are preserved exactly.

…th migration consequence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mike-gorman-bitwarden

Copy link
Copy Markdown
Contributor Author

Thanks.

  1. Fair. The 503 row only covered dependencies. I've widened it to cover the service itself being overloaded, which matches RFC 9110. Retry-After comes with those RFCs. Which statuses a caller retries is up to the client (the SDK's resilience handler), not the API contract.
  2. Covered under idempotency on ADR-0042.
  3. How a service checks references, and what it does when a referenced resource is deleted later, is up to that service and the MSA design. The API standard only says the reference must be valid at the time of the request.
  4. Already the case: ADR-0044 requires conflict detection to use version, and comparing updatedAt was a rejected option. These standards only apply to new services, so the Cipher RevisionDate check isn't affected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants