---
id: 67
title: "The API I'd build today"
status: current
kind: note
date: 2026-08-30
ai_assisted: true
authors:
  - "Theo Zourzouvillys"
tags: [api, design, architecture, http, interop]
summary: "What a new API needs before the first endpoint: operations kept apart, idempotency keys that replay the response, state tokens, DPoP, quota in requests and in work, regions, an archive. Cheap to decide before you have callers, a migration afterwards. Specified in ZBP-7."
supersedes: null
superseded_by: null
aliases: []
references:
  - id: dpop
    title: "RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)"
    url: https://www.rfc-editor.org/rfc/rfc9449.html
    abstract: "Defines a mechanism to bind an access token to a client-held key: the client sends a signed DPoP proof header on each request, so a stolen token cannot be replayed by a party that doesn't hold the corresponding private key."
  - id: idempotency
    title: "The Idempotency-Key HTTP Header Field (IETF draft)"
    url: https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
    abstract: "Specifies an `Idempotency-Key` request header letting a client mark a mutating request with a unique key so servers can deduplicate retried requests, together with the expected server behaviour for concurrent, replayed, and mismatched-payload cases."
  - id: ratelimit
    title: "RateLimit header fields for HTTP (IETF draft)"
    url: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
    abstract: "Defines the `RateLimit` and `RateLimit-Policy` response header fields, expressing a server's quota policies and the caller's remaining quota as structured fields, including a quota-unit parameter, so a policy can be denominated in something other than requests."
  - id: servertiming
    title: "Server Timing (W3C)"
    url: https://www.w3.org/TR/server-timing/
    abstract: "Defines the `Server-Timing` response header field, letting a server report named metrics and durations for the work it did serving a request, and exposes them to the client through the browser performance APIs."
  - id: rfc8693
    title: "RFC 8693 — OAuth 2.0 Token Exchange"
    url: https://www.rfc-editor.org/rfc/rfc8693.html
    abstract: "Defines exchanging one security token for another, and (the part most people miss) separates delegation from impersonation: an `act` claim naming the acting party in a chain, and a `may_act` claim stating who is permitted to act for whom."
  - id: rfc9457
    title: "RFC 9457 — Problem Details for HTTP APIs"
    url: https://www.rfc-editor.org/rfc/rfc9457.html
    abstract: "The standard shape for machine-readable HTTP error responses: a `type` URI identifying the error class, `title`, `status`, `detail`, `instance`, and extension members. Obsoletes RFC 7807; the answer to every hand-rolled { \"error\": \"...\" } envelope."
  - id: deprecation
    title: "RFC 9745 — The Deprecation HTTP Response Header Field"
    url: https://www.rfc-editor.org/rfc/rfc9745.html
    abstract: "Defines a `Deprecation` response header carrying the date a resource became (or becomes) deprecated, designed to be used alongside the `Sunset` header of RFC 8594, which gives the date it stops responding at all."
  - id: aip151
    title: "AIP-151 — Long-running operations (Google API Improvement Proposals)"
    url: https://google.aip.dev/151
    abstract: "The design pattern for calls that can't complete within one request: the method returns an `Operation` resource immediately, which the client polls or waits on, and which carries the eventual response or error as part of its own state."
crossrefs:
  ZFN-30: "Why almost every mechanism here is an existing header field or token format rather than a private invention."
  ZFN-45: "The habit that makes adopting a standard cheaper than inventing one, which is reading the specification before deciding it does not fit."
  ZFN-13: "What a quota system degrades into when its limits are wrong or missing, and why a retry budget is a client-side control."
  ZFN-53: "The reasoning behind charging for work rather than requests, so the expensive path is expensive for whoever chose it."
  ZFN-56: "The full argument for identifiers that carry their type and reveal nothing, including why the format can never be changed."
  ZBP-7: "The normative version of this note. Everything argued here as a position is specified there as numbered requirements, with wire formats, algorithms, test vectors and a conformance checklist."
  ZFN-51: "The layer everything on this list rides on. That note argues for defining the envelope before the first endpoint; this one is the inventory of what ends up in it."
  ZFN-14: "The reason any of this reaches clients. A behaviour specified in a document and hand-written into four SDKs is four behaviours."
  ZFN-25: "Where the state-token argument is made properly, including why the client is the right place to hold it."
  ZFN-6: "The full argument for binding a credential to a key the client holds, rather than accepting bearer semantics as the default."
  ZFN-18: "Why the limiter belongs at the edge rather than in each service, which is what makes a second quota currency practical at all."
  ZFN-58: "The other half of retry safety. An idempotency key makes a retry harmless; the error taxonomy is what tells the client whether to retry at all."
  ZFN-21: "The discipline that keeps a cache from becoming a dependency, and the reason a cold cache has to be a performance event and not an outage."
  ZFN-40: "Why an internal hop adds its own signature instead of dropping the caller's token and continuing as the system."
  ZFN-61: "The deadline half of the envelope, and why a cancelled call is not the same as a call that didn't happen."
  ZFN-57: "The collision the request archive creates. Once you keep every request body for a year, an erasure request has a second, harder place to reach."
  ZFN-65: "Why the archive is written as a buffered stream of batches rather than a row per request."
---
## TL;DR

Before the first endpoint of a new API ships, decide the concerns every endpoint will share. As of
today that list is:

- **Four kinds of operation** (queries, mutations, events, and long-running operations) kept apart,
  because they have different guarantees and collapsing them produces a mutation nobody can safely
  retry and a wait that means polling.
- **An idempotency key that replays the stored response**, not one that merely suppresses a second
  execution.
- **A state token on every response**, so read-your-writes is a property of the protocol rather than
  of your database.
- **Sender-constrained credentials, and a principal chain** - support access, impersonation, and
  agents acting for users are one mechanism, not three flags.
- **Quota in two currencies**, requests and work, with cost estimated before execution and measured
  after.
- **Regional endpoints**, with anycast used for discovery rather than in the serving path, and
  failover treated as an entirely new session.
- **A request id on every response and an archive of every call**, written off the critical path.
- **Typed, prefixed, opaque identifiers** - the one thing here you genuinely cannot change later.
- **A schema that is introspectable, permissioned per field, extensible by customers, and evolved by
  expand/migrate/contract** - plus a test mode, and generated clients, without which none of the
  above reaches a caller.

These are not the most important properties an API can have. They are the ones where deciding late
is categorically worse than deciding early, which is why they belong in one list and on day one.
**The normative version is [ZBP-7](/zbp/7-cross-cutting-api-contract/)** (requirements, wire formats,
algorithms, test vectors, conformance checklist). This is the argument for bothering.

## Context

An idempotency key costs almost nothing to design before the API has any callers. Afterwards it is a
coordinated migration across every SDK, every customer integration, and every internal service that
already talks to you. Same decision, wildly different price.

That holds for every concern in this note, and it is the only reason they belong in one list. It
also carries an awkward consequence: you have to settle them before there is any evidence you need
them, which is precisely why they get skipped.

The failure mode is never a team deciding against one of these. It's a team never reaching the
decision. Idempotency arrives on the three endpoints that caused an incident. Rate limiting arrives
the week a customer's retry loop saturates a shard. Regions arrive with the first contract that has a
residency clause, at which point the hostname is in ten thousand config files. Each retrofit is
individually survivable and collectively the reason a five-year-old API has four ways to paginate.

This isn't a demand to build all of it before launch. Most items are a day-one **shape** with a
trivial implementation behind it: a scope registry can be a YAML file, a request archive one JSON
object per line in a bucket, a regional partition a single region. What costs you isn't the
machinery, it's not having the concept - adding a second region is work, but adding the *idea* of a
region to an API that assumed one is a different order of problem.

Nor is it original. Nearly every line is an existing note applied to one surface, or a standard I'd
rather adopt than relitigate ([ZFN-30](/zfn/30-use-standards-dont-reinvent/),
[ZFN-45](/zfn/45-read-the-standards/)).

## Recommendation

**Treat the cross-cutting shape of the API as the day-one deliverable, and the first endpoint as the
thing you add to it.**

The reason to take the list as a set rather than a menu is that the items only work together.
Idempotency keys without an enumerated error taxonomy don't make retries safe, because the client
still can't tell what to retry ([ZFN-58](/zfn/58-errors-are-part-of-the-contract/)). Quotas without a
cost signal can't be enforced against the caller who matters, because the limiter has no number until
the expensive work is already done. A state token that only some endpoints return gives
read-your-writes *usually*, which is the intermittent, unreproducible bug it existed to eliminate
([ZFN-25](/zfn/25-read-your-writes-version-token/)). Partial adoption of most of these is worse than
none, because none is at least honest about what it doesn't give you.

The linchpin is that **the client is part of the implementation**. Reusing an idempotency key across
retries, echoing the state token, honouring a retry budget, staying in-region, treating failover as a
new session: every one of these is caller-side behaviour. A specification that four hand-written SDKs
implement independently is four dialects, three of them subtly wrong and none of them wrong in the
same way. That is why generating the clients from the schema is the requirement that makes the rest
of them real ([ZFN-14](/zfn/14-schema-first-apis-generate-clients/),
[ZFN-51](/zfn/51-design-the-request-envelope-first/)).

Everything else is specified in [ZBP-7](/zbp/7-cross-cutting-api-contract/): what an idempotency
record stores, how a fingerprint is computed, what a discovery response contains, how admission
control reconciles an estimate against a measurement, and the hundred-odd numbered requirements that
make those testable. Hand that to whoever is building it.

> [!aside]
>
> What convinces me this list is the right length is how consistently the retrofits arrive in the
> same order, years apart, at unrelated companies. Idempotency after the first double-write.
> Cost-based limits after the first client with a `while true` loop. Regions after the first
> residency clause. Delegation after the first support engineer needs to see what a customer sees.
> It reads less like a series of accidents than a fixed sequence of lessons every API eventually pays
> for, and the only variable is whether you pay before or after you have customers.

## Consequences

**Easier:**

- **Client retries become correct rather than hopeful.** A key that replays a stored response, a
  taxonomy that says whether to retry, and a budget that stops the storm are one mechanism from the
  caller's side.
- **Incidents get shorter**, because "what did they actually send?" is a query against the archive
  rather than an appeal to a sampled log.
- **The security review has a surface to review.** A scope registry, a delegation chain, and one
  serialisation-time visibility filter are auditable; the same decisions spread across handlers
  aren't.
- **Growth is mostly additive** - regions, scopes, customer fields, and new operations slot into
  concepts that already exist.

**Harder:**

- **Day one is materially longer.** None of this ships an endpoint, and all of it is work you do
  before the first demo. That is why it gets skipped, and exactly why it is cheapest then.
- **You're running stateful infrastructure you'd rather not.** Idempotency records, state tokens,
  quota counters, and an archive are all storage with lifecycle, cost, and failure modes of their own.
- **Per-tenant schema is a real subsystem**, and every part of it (validation, indexing,
  introspection, documentation) is harder than its static form.
- **Explicit failover looks worse than transparent failover**, right up to the first time transparent
  failover silently serves a stale write.

**New obligations:**

- **The scope registry and the cost estimates have to stay true.** Both rot silently, and both are
  discovered to be wrong by someone outside the team.
- **The archive is a data-protection surface.** Retention, redaction, access control, and erasure
  apply to it exactly as to the primary store ([ZFN-57](/zfn/57-deletion-is-a-feature/)).
- **The list has an expiry date.** It's the current answer, not a permanent one, and it should be
  re-read against the standards that exist when you read it.

## References

- [ZBP-7](/zbp/7-cross-cutting-api-contract/) - the specification this note argues for.
- [ZFN-51](/zfn/51-design-the-request-envelope-first/) and
  [ZFN-14](/zfn/14-schema-first-apis-generate-clients/) - the envelope, and the generated clients that
  turn any of this from a document into a behaviour.
- [ZFN-25](/zfn/25-read-your-writes-version-token/), [ZFN-58](/zfn/58-errors-are-part-of-the-contract/)
  and [ZFN-61](/zfn/61-propagate-the-deadline/) - state tokens, errors, and deadlines.
- [ZFN-18](/zfn/18-enforce-quotas-at-ingress/), [ZFN-13](/zfn/13-load-shedding-and-flow-control/) and
  [ZFN-53](/zfn/53-attack-the-unit-economics-of-abuse/) - quotas, shedding, and making abuse
  expensive.
- [ZFN-56](/zfn/56-typed-prefixed-ids/), [ZFN-65](/zfn/65-journal-writes-micro-batches/) and
  [ZFN-57](/zfn/57-deletion-is-a-feature/) - identifiers, how the archive is written, and what it
  obliges you to delete.

## Changelog

- **2026-08-30**: First published as a Field Note.
- **2026-08-30**: Amended - anycast belongs in endpoint *discovery*, not the serving path (with the
  lookup piggybacked on authentication); added client version negotiation and upgrade signalling.
- **2026-08-30**: Amended - long-running operations need a wait that isn't a poll loop, streamed
  progress events, and a state machine declared per operation type.
- **2026-08-30**: Amended - added typed and prefixed identifiers, batch and partial-failure
  semantics, a request id on every response, and test mode as a day-one path; plus unknown-value
  tolerance, overlapping credential validity, and naming the tenant explicitly on the request.
- **2026-08-30**: The normative specification moved to ZBP-7, which carries the requirements, wire
  formats, algorithms, test vectors and conformance checklist. This note keeps the argument for
  deciding them together and early.
