Designing Platform APIs and Extension Boundaries
The previous article, Developer Portals, Catalogs, and Provisioning Workflows, described a portal, a catalog, and provisioning workflows, and it explained why a provisioning run needs a key, a durable record, and a permission model with three separate identities. This article assumes you took those points from it. It starts one level lower, at the interface those pieces call. A portal is one client of the platform. A command-line tool, a pipeline, a team’s own automation, and a third party’s extension are all clients too, and each of them will build on whatever the API promises.
That makes the platform API the most expensive thing to change. A template can be regenerated and a screen can be redesigned, but a client written against an API by a team you have never met keeps calling it until someone makes it stop. Designing the interface therefore means deciding in advance what the platform commits to, for whom, and for how long. This article covers five parts of that decision: the lifecycle of an API and its versions, tenancy, idempotency, the permissions of extensions, and compatibility and deprecation.
The scenario and code are illustrative. The tenant names, resource names, credentials, and dates are invented, and the placeholder tokens are not real secrets. The example in the middle of this article is a small in-process API core written for this article and run locally with Python 3.12 and no third-party packages. It receives dictionaries instead of network requests, so it demonstrates decisions and contains no server. It is not a production design, and its output is not a measurement of any product.
What a platform API is made of
Resources, operations, and who the clients are
Most platform APIs expose resources, such as environments, services, and databases, and operations on them. Two models appear in practice, and the previous articles used both. An imperative operation asks the platform to do something and returns a result or a handle. A declared resource records a target that a controller works toward. Kubernetes’ documentation on custom resources gives a test for the second model: a declarative API has a small number of relatively small objects, the objects configure applications or infrastructure, they change infrequently, and clients do not need transactions across them. It lists the opposite signs as imperative requests with synchronous responses, operation identifiers or separate operation objects, and RPC-style interaction (Kubernetes, custom resources).
This distinction decides the shape of the interface. A resource whose target should keep holding, such as an environment’s size, belongs in the declared model, and its desired state sits in a spec while observations sit in a status. An action with a definite beginning and end, such as running a migration, fits an operation object. The control plane entry names the split: the control plane records how a system should operate, and the data plane performs the work. A platform API is almost always a control-plane API, which means its failures are usually about configuration and coordination, not about the traffic the workloads serve.
Operations that take time
Provisioning rarely finishes within a request. Google’s API improvement proposal for long-running operations describes the usual solution: the method returns an operation resource with a name, a done flag, optional metadata for progress, and either a response or an error when it completes, and clients poll the operation until it is done. The same document suggests ten seconds as a rule of thumb for work long enough to deserve this treatment, and it recommends that completed operations may expire, with 30 days as its suggestion (Google AIP-151). Those figures are guidance from one organization and not a standard. What generalizes is the shape: a durable handle the client can keep and query, so that a lost connection never means a lost result.
This shape connects to the previous article. The operation resource is the client-visible face of the provisioning run record. If the operation exposes step-level status and distinguishes failed, still running, and unknown, a user can act on it without a ticket.
API lifecycle
An API passes through stages, and each stage is a different promise. A useful design states the promise for every stage in advance, because consumers decide how much to invest in an interface based on it.
| Stage | What the platform promises | What consumers should expect | Typical signal |
|---|---|---|---|
| Preview | The interface exists for feedback; it may change or disappear | Build experiments only, with no production dependence | Name or version marker such as alpha; documentation says so |
| Stable | Compatible changes only within a version; incompatible changes require a new version | Production use; migration only when a new version is announced | Version without a pre-release marker; published compatibility rules |
| Deprecated | Still works and is supported while consumers move; has a published timeline | Plan and carry out migration within the window | Response headers, warnings in tools, notices to owners |
| Sunset | Scheduled to stop answering at a stated time | Migration should already be complete | Sunset date in headers and documentation |
| Removed | Gone; calls fail in a documented way | No remaining use should exist | Error response with a pointer to the replacement |
Kubernetes publishes a worked example of such a policy. Its API versions are alpha, beta, and generally available, and its deprecation policy states that API elements can be removed only by incrementing the API group version, that objects must round-trip between versions without loss, and that lifetimes differ by stability level: a generally available version is not removed within a major version, a beta version has a deprecation window of at least nine months or three minor releases, whichever is longer, and alpha versions can be removed in any release (Kubernetes deprecation policy). The page also describes visibility measures for deprecated endpoints: a warning header on responses, an annotation on audit events, and a metric in the API server. The specific windows belong to Kubernetes and to its release cadence. A platform with fewer consumers and a different release rhythm needs different numbers, but it still needs numbers, written down.
Lifecycle has a human side. Someone has to be accountable for moving an API from one stage to the next, for approving the timeline, and for deciding when a consumer’s special circumstances justify an extension. The service owner entry describes that mandate, including retirement. The design question is whether the owner’s authority is real: can the owner enforce a sunset date when a large internal consumer asks for a delay? That is a governance decision to make before the date arrives.
Tenancy
What a tenant is for this API
A tenant is a group served by a shared system. The multi-tenancy entry notes that no universal definition exists, and Kubernetes’ multi-tenancy guidance distinguishes two common patterns: multiple teams within one organization, who usually have some trust in each other and direct access, and multiple customers of a vendor, who lack direct access and may not trust each other at all (Kubernetes, multi-tenancy). A platform serving internal teams usually sits near the first pattern, but its API design should state which it assumes, because the isolation requirements differ. The same guidance describes “hard” and “soft” multi-tenancy as a spectrum of isolation techniques and warns that the terms are not strictly defined.
In an API, tenancy shows up in three places: how requests are attributed to a tenant, how objects are scoped, and how consumption is bounded.
Attribution, scoping, and enforcement
The central rule of tenant isolation is that a request must not choose its own security boundary. A caller who supplies another tenant’s identifier in a URL must not obtain that tenant’s data. The platform derives the tenant from the verified credential and checks the requested object against it. The example below follows that rule: the tenant comes from the token, never from the path. OWASP’s authorization guidance gives the same direction: deny by default, enforce least privilege, check permissions on every request for the specific object, and make the server-side check decisive instead of relying on obscure identifiers or on client-side logic (OWASP Authorization Cheat Sheet).
Scoping affects names. If two tenants may both create an environment called dev-1, then names are unique per tenant, and every internal lookup, cache entry, idempotency record, and log query must include the tenant. A shared global map keyed only by name is a cross-tenant leak waiting for a first collision. The example scopes names and idempotency keys by tenant.
A design decision hides in the error code. When tenant B asks for tenant A’s object, the platform can answer forbidden or not found. Forbidden confirms the object exists. Not found does not. The example answers not found for objects in other tenants, which treats existence as information the caller is not entitled to. Whether that matters depends on the threat model, and the cost is some added confusion for a legitimate user who mistypes a tenant context, so document whichever choice you make.
Consumption is the third part. Isolation of data does not prevent one tenant from using the API so heavily that others slow down, which is the noisy neighbor problem. Per-tenant limits and quotas address it, and throttling is the usual mechanism for the API itself. A throttled response should tell the client when to try again: the Retry-After entry describes the header and its two value forms. Kubernetes’ guidance mentions API priority and fairness for keeping one tenant from overwhelming the cluster API. The example in this article does not implement quotas or rate limits, and a real platform API needs them.
A small API core
The script below implements the pieces discussed in this section and the next two: tenant-scoped resources, tenant-scoped idempotency keys with a request fingerprint, ETag preconditions on update, extension scopes, error bodies in the RFC 9457 shape, and deprecation headers on an old version path. It handles requests as dictionaries and uses an in-memory store.
"""Illustrative platform API core (author-written, stdlib only). Requests and responses are plain dicts;
no network is involved. Demonstrates tenant scoping, idempotency keys, ETag preconditions,
extension scopes and deprecation signalling."""
import hashlib, json
class Platform:
def __init__(self):
self.envs = {} # (tenant, name) -> {"spec":..., "rev": int}
self.idem = {} # (tenant, key) -> {"fp": str, "resp": dict}
self.audit = []
self.tokens = { # token -> identity (placeholders)
"tok-a-dev": {"tenant": "tenant-a", "scopes": {"env:write", "env:read"}},
"tok-a-ro": {"tenant": "tenant-a", "scopes": {"env:read"}},
"tok-b-dev": {"tenant": "tenant-b", "scopes": {"env:write", "env:read"}},
"ext-cost": {"tenant": "tenant-a", "scopes": {"env:read"}}, # an extension: read-only
}
self.new_ids = 0
@staticmethod
def fp(body): # fingerprint of the request meaning
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:12]
@staticmethod
def etag(rec):
return f'"{rec["rev"]}"'
def problem(self, status, type_, title, detail=None, **ext):
body = {"type": f"https://platform.example/problems/{type_}", "title": title, "status": status}
if detail: body["detail"] = detail
body.update(ext)
return {"status": status, "headers": {"content-type": "application/problem+json"}, "body": body}
def handle(self, req):
ident = self.tokens.get(req.get("token"))
if not ident:
return self.problem(401, "unauthenticated", "Unknown credential")
tenant = ident["tenant"] # tenant comes from the credential, never from the URL
method, path, body, h = req["method"], req["path"], req.get("body"), req.get("headers", {})
parts = path.strip("/").split("/")
# v1 is deprecated: it answers, but says so (RFC 9745 / RFC 8594 headers)
dep = {}
if parts[0] == "v1":
dep = {"deprecation": "@1717200000", "sunset": "Tue, 31 Dec 2024 23:59:59 GMT",
"link": '<https://platform.example/docs/migrate-v2>; rel="deprecation"'}
resp = self._route(ident, tenant, method, parts[1:], body, h)
resp["headers"] = {**resp.get("headers", {}), **dep}
self.audit.append((tenant, req.get("token"), method, path, resp["status"]))
return resp
def _route(self, ident, tenant, method, parts, body, h):
if parts[:1] != ["environments"]:
return self.problem(404, "not-found", "No such resource")
need = "env:read" if method == "GET" else "env:write"
if need not in ident["scopes"]:
return self.problem(403, "insufficient-scope", "Credential lacks a required scope", required=need)
if method == "POST" and len(parts) == 1:
key = h.get("idempotency-key")
if not key:
return self.problem(400, "missing-idempotency-key", "POST requires Idempotency-Key")
slot = (tenant, key) # scoped by tenant, not global
if slot in self.idem:
prior = self.idem[slot]
if prior["fp"] != self.fp(body):
return self.problem(422, "idempotency-key-reuse", "Key was used with different parameters")
return {**prior["resp"], "headers": {**prior["resp"]["headers"], "idempotent-replay": "true"}}
name = body["name"]
if (tenant, name) in self.envs:
return self.problem(409, "already-exists", "Environment name already used", name=name)
rec = {"spec": body, "rev": 1}
self.envs[(tenant, name)] = rec
resp = {"status": 201, "headers": {"etag": self.etag(rec)}, "body": {"name": name, **body}}
self.idem[slot] = {"fp": self.fp(body), "resp": resp} # recorded with the effect
return resp
if len(parts) == 2:
rec = self.envs.get((tenant, parts[1]))
if rec is None: # other tenants' objects look absent
return self.problem(404, "not-found", "No such environment")
if method == "GET":
return {"status": 200, "headers": {"etag": self.etag(rec)}, "body": rec["spec"]}
if method == "PUT":
if "if-match" not in h:
return self.problem(428, "precondition-required", "PUT requires If-Match")
if h["if-match"] != self.etag(rec):
return self.problem(412, "stale-revision", "Object changed since you read it", current=self.etag(rec))
rec["spec"], rec["rev"] = body, rec["rev"] + 1
return {"status": 200, "headers": {"etag": self.etag(rec)}, "body": rec["spec"]}
return self.problem(405, "method-not-allowed", "Unsupported method")
def show(label, r):
keep = {k: v for k, v in r["headers"].items() if k in ("etag", "idempotent-replay", "deprecation", "sunset")}
b = r["body"]; b = {k: b[k] for k in b if k in ("type", "status", "required", "current", "name")} if r["status"] >= 400 else b
print(f"{label:<44} {r['status']} {keep or ''} {b}")
if __name__ == "__main__":
p = Platform()
spec = {"name": "dev-1", "size": "small"}
r1 = p.handle({"token":"tok-a-dev","method":"POST","path":"/v2/environments","body":spec,"headers":{"idempotency-key":"k1"}}); show("create (key k1)", r1)
r2 = p.handle({"token":"tok-a-dev","method":"POST","path":"/v2/environments","body":spec,"headers":{"idempotency-key":"k1"}}); show("retry same key, same body", r2)
r3 = p.handle({"token":"tok-a-dev","method":"POST","path":"/v2/environments","body":{**spec,"size":"large"},"headers":{"idempotency-key":"k1"}}); show("same key, different body", r3)
r4 = p.handle({"token":"tok-a-dev","method":"POST","path":"/v2/environments","body":spec,"headers":{"idempotency-key":"k2"}}); show("new key, same name", r4)
r5 = p.handle({"token":"tok-b-dev","method":"POST","path":"/v2/environments","body":spec,"headers":{"idempotency-key":"k1"}}); show("tenant B reuses key k1 and name", r5)
r6 = p.handle({"token":"tok-b-dev","method":"GET","path":"/v2/environments/dev-1"}); show("tenant B reads its own dev-1", r6)
r7 = p.handle({"token":"tok-b-dev","method":"GET","path":"/v2/environments/nope"}); show("tenant B reads a name it never created", r7)
p.handle({"token":"tok-a-dev","method":"POST","path":"/v2/environments","body":{"name":"only-a","size":"small"},"headers":{"idempotency-key":"k3"}})
r8 = p.handle({"token":"tok-b-dev","method":"GET","path":"/v2/environments/only-a"}); show("tenant B reads tenant A's only-a", r8)
g = p.handle({"token":"tok-a-dev","method":"GET","path":"/v2/environments/dev-1"}); show("A reads dev-1", g)
u1 = p.handle({"token":"tok-a-dev","method":"PUT","path":"/v2/environments/dev-1","body":{"name":"dev-1","size":"medium"},"headers":{"if-match":g["headers"]["etag"]}}); show("A updates with current ETag", u1)
u2 = p.handle({"token":"tok-a-dev","method":"PUT","path":"/v2/environments/dev-1","body":{"name":"dev-1","size":"large"},"headers":{"if-match":g["headers"]["etag"]}}); show("second writer with stale ETag", u2)
u3 = p.handle({"token":"tok-a-dev","method":"PUT","path":"/v2/environments/dev-1","body":{"name":"dev-1","size":"large"}}); show("update without If-Match", u3)
e1 = p.handle({"token":"ext-cost","method":"GET","path":"/v2/environments/dev-1"}); show("extension reads (env:read)", e1)
e2 = p.handle({"token":"ext-cost","method":"PUT","path":"/v2/environments/dev-1","body":{"name":"dev-1","size":"small"},"headers":{"if-match":'"2"'}}); show("extension tries to write", e2)
d1 = p.handle({"token":"tok-a-dev","method":"GET","path":"/v1/environments/dev-1"}); show("same read through deprecated /v1", d1)
print("audit rows:", len(p.audit), "| environments stored:", sorted(p.envs))
Output from an actual run:
create (key k1) 201 {'etag': '"1"'} {'name': 'dev-1', 'size': 'small'}
retry same key, same body 201 {'etag': '"1"', 'idempotent-replay': 'true'} {'name': 'dev-1', 'size': 'small'}
same key, different body 422 {'type': 'https://platform.example/problems/idempotency-key-reuse', 'status': 422}
new key, same name 409 {'type': 'https://platform.example/problems/already-exists', 'status': 409, 'name': 'dev-1'}
tenant B reuses key k1 and name 201 {'etag': '"1"'} {'name': 'dev-1', 'size': 'small'}
tenant B reads its own dev-1 200 {'etag': '"1"'} {'name': 'dev-1', 'size': 'small'}
tenant B reads a name it never created 404 {'type': 'https://platform.example/problems/not-found', 'status': 404}
tenant B reads tenant A's only-a 404 {'type': 'https://platform.example/problems/not-found', 'status': 404}
A reads dev-1 200 {'etag': '"1"'} {'name': 'dev-1', 'size': 'small'}
A updates with current ETag 200 {'etag': '"2"'} {'name': 'dev-1', 'size': 'medium'}
second writer with stale ETag 412 {'type': 'https://platform.example/problems/stale-revision', 'status': 412, 'current': '"2"'}
update without If-Match 428 {'type': 'https://platform.example/problems/precondition-required', 'status': 428}
extension reads (env:read) 200 {'etag': '"2"'} {'name': 'dev-1', 'size': 'medium'}
extension tries to write 403 {'type': 'https://platform.example/problems/insufficient-scope', 'status': 403, 'required': 'env:write'}
same read through deprecated /v1 200 {'etag': '"2"', 'deprecation': '@1717200000', 'sunset': 'Tue, 31 Dec 2024 23:59:59 GMT'} {'name': 'dev-1', 'size': 'medium'}
audit rows: 16 | environments stored: [('tenant-a', 'dev-1'), ('tenant-a', 'only-a'), ('tenant-b', 'dev-1')]
Read the output in groups. The first five lines show creation and retry. The next lines show tenant-scoped reads. The three lines after the read by tenant A show conditional updates. The two extension lines show scopes, and the final lines show the deprecated path and the audit count. The next sections explain each group.
Idempotency at the API boundary
The previous article explained why a retried provisioning call needs a key. This section covers what the API must do to honor one. Stripe’s documentation describes one widely used contract: the client generates a key for the operation, the server saves the status code and body of the first request made with that key, whether it succeeded or failed, and returns the same result for later requests with that key. The server compares the incoming parameters with the original request and returns an error if they differ. Keys are up to 255 characters, may be pruned after 24 hours, and are accepted on POST requests, while GET and DELETE are already idempotent by definition. The documentation also says results are saved only after execution of an endpoint begins, so a request that fails validation or conflicts with a concurrent request can be retried (Stripe, idempotent requests). The AWS Builders’ Library article describes the matching server-side discipline: record the token atomically with the effect and return the same response on a retry, and reject a retry that reuses the token with different parameters (Amazon Builders’ Library).
The example’s first five lines exercise that contract. The first create with key k1 returns 201. A retry with the same key and body returns the same 201 and the same ETag, flagged as a replay by a response header. The same key with a different body returns 422 with an idempotency-key-reuse problem, because accepting it silently would discard the second request’s intent. A different key with the same name returns 409, because the key identifies an attempt while the name identifies the resource, and two separate attempts to create one named resource are a real conflict. Finally, tenant B reuses the same key and the same name and succeeds, because both are scoped by tenant. That last line is the check that a global key space would fail.
The choice of 422 for key reuse is this article’s, not a requirement of any standard. The Stripe and AWS pages describe an error for mismatched parameters without prescribing one status code for every API. The IETF httpapi working group has an Idempotency-Key header draft, but its current datatracker status is an expired Internet-Draft, so it should not be cited as a standard. Define your own header name, scope, retention, and error code and document them.
The idempotency key entry lists additional cases that the example does not implement. Two requests with the same key can arrive concurrently, and something has to serialize them, such as a uniqueness constraint. An in-progress request needs an answer that does not start a second attempt, and an attempt with an unknown outcome needs reconciliation. The example is single-threaded and records the result immediately with the effect, which avoids those cases and also hides them. The retention period matters too: after a key record is pruned, a retry may run again, so the documented retry window must be shorter than the retention period.
Conditional updates
Creation retries are one source of duplicates. Concurrent updates are another. Two clients read an environment, each changes the size, and the second write silently overwrites the first. The standard HTTP tool is a validator. The server returns an ETag with each representation, the client sends it back in If-Match on update, and the server rejects the write with 412 Precondition Failed if the current entity-tag no longer matches. RFC 9110 defines this precondition, and it specifies strong comparison for If-Match (RFC 9110). RFC 6585 adds 428 Precondition Required for servers that insist on conditional requests, precisely to prevent the lost-update problem (RFC 6585). This is optimistic concurrency control: no locks, a version check at write time, and a retry after a conflict.
The example shows the three outcomes. An update with the current ETag returns 200 and a new ETag. A second writer who read the same version and uses the stale ETag receives 412, with the current ETag in the problem body so the client can re-read and decide. An update without If-Match receives 428. Requiring the precondition is a choice. It prevents accidental overwrites and makes simple scripts harder to write, and it is worth deciding explicitly. Kubernetes uses a resource version for the same purpose and returns a conflict when a write carries a stale one (Kubernetes API concepts).
Note what the precondition does not do. It prevents lost updates to one object. It does not make a multi-object change atomic, and it does not tell the client whether the new value is wise. A client that retries after 412 by blindly re-sending its old body has reintroduced the problem it was supposed to avoid.
Errors that clients can act on
Error bodies are part of the interface, and the example returns them as problem details. RFC 9457 defines the application/problem+json media type with a type URI identifying the problem category, a title, a status, an occurrence-specific detail, and an instance, and it allows extension members. Clients must ignore extensions they do not recognize, which lets a problem type grow without breaking clients (RFC 9457). In the example, the 403 carries a required extension naming the missing scope and the 412 carries the current ETag. Those are the fields client logic should use. The problem details entry advises clients not to parse the detail text, so anything a program needs belongs in a typed member.
Extension permissions
Why extensions are different from clients
A platform that grows needs extension points: a cost-reporting plugin, a policy check on every request, a custom resource type, a new action in the portal. An extension is code that someone other than the platform team runs, and it often runs with more authority than an ordinary client because it sits inside a flow. That is the boundary to design.
| Mechanism | What the extension can do | What can go wrong | Boundary to design |
|---|---|---|---|
| Client with API token | Call the API with its granted permissions | Over-broad token; token lives too long; cannot be traced to a person | Scopes per token, expiry, audit that records the initiating identity |
| Webhook on requests (Kubernetes admission) | Mutate or reject requests as they are processed | An unavailable or slow webhook blocks every matching request | Timeouts, a failure policy decided per control, and a limited match scope |
| Custom resource and controller | Add new resource types and act on them | A resource stored with no controller running; a controller with excess power | Narrow controller permissions, status reporting, ownership of the controller |
| Portal plugin | Add screens and backend actions | The plugin’s backend uses its own broad credential | Policy evaluated in the backend on the request path; delegated or recorded user identity |
Kubernetes documents the webhook case with unusual candor. Admission webhooks are part of the control plane, mutating webhooks run first and may change objects, validating webhooks run afterward and can reject, and a failure policy decides whether the request is rejected or allowed when the webhook is unavailable or times out. The default timeout is 10 seconds, the documentation recommends short timeouts, and it warns that a misconfigured or unavailable webhook can block matching API requests and compromise cluster availability (Kubernetes, dynamic admission control). The lesson for a platform API is general. An extension that can reject requests is a dependency of every request it matches, and failing closed trades availability for enforcement while failing open trades the reverse. Decide per control and record why.
Scopes and where they are enforced
Permissions for extensions follow the same pattern as permissions for people, with sharper edges. Give each extension its own credential. Scope the credential to the actions and resources it needs. The least privilege entry applies as it does to any service identity. A cost-reporting extension needs to read environments and has no reason to create them. The access token entry explains the three roles involved: the authorization server issues a token, the client presents it, and the resource server decides whether to accept it. The decision belongs in the platform API, on every request.
The example’s extension credential ext-cost carries only the read scope. Its read succeeds and its attempted write returns 403 with the required scope named. The platform’s authorization check ran on the action and the object, not on the token’s existence. Backstage’s permission framework shows the decoupling for portal plugins: policies decide with a user and a permission as input, and plugins enforce (Backstage permission concepts).
Four more points complete the boundary. Install per tenant when extensions act on tenant data, so that one tenant’s extension cannot see another’s objects. Review extensions before granting write scopes, and record who approved them. Make revocation fast and test it, because a token that cannot be withdrawn is a standing privilege. And keep the audit log honest: the example records the token and the status of each call, and a real record also needs the initiating user where an extension acts on a person’s behalf.
Compatibility and deprecation
What counts as compatible
Compatibility is a statement about a defined interface. Google’s AIP-180 states a conservative rule set. Within a major version, adding interfaces, methods, messages, fields, and enum values is generally compatible, but new required fields must not be added to existing request messages; removing or renaming components is incompatible, as is changing a field’s type, changing resource name formats, or changing field semantics and default values. It also acknowledges that code may not handle new enum values in responses gracefully and advises caution and documentation (Google AIP-180). The breaking change entry gives the same list from the consumer’s side and adds that a stable shape can still hide a changed meaning: a field that keeps its name and type but is now computed differently has broken its consumers silently.
Two practical rules follow. Document the semantics of every field, since a structural check cannot see a semantic change. And publish a tolerance rule for clients: ignore unknown fields, and treat unknown enum values as a defined fallback. Martin Fowler’s discussion of consumer-driven contracts makes the same appeal to being liberal in what is received, so that providers may add optional elements without breaking consumers (Martin Fowler). Both sides share the burden: the provider avoids unsafe changes, and the client tolerates safe ones.
Versions, conversion, and moving clients
When a change is incompatible, the platform publishes a new version and runs both for a time. Kubernetes’ guidance for custom resource versions illustrates the mechanics: a resource definition can list several versions, each served or not, with exactly one storage version; a conversion strategy translates between versions, either trivially when only the version string changes or through a conversion webhook when schemas differ; and a deprecated version can carry a deprecation warning (Kubernetes, CRD versioning). Removal follows a sequence: confirm clients have moved, stop serving the old version, make sure stored objects were upgraded, and only then drop it. The round-trip requirement in the deprecation policy above, converting an object to another version and back without loss, is what makes running two versions safe. A version that cannot carry a field of the other version loses data on conversion.
This is the expand and contract pattern at the API level: add the new version while keeping the old, migrate clients, remove the old when supported consumers no longer use it. Semantic Versioning gives the vocabulary for libraries, and its deprecation rule asks for a minor release that announces the deprecation before a major release that removes the functionality (Semantic Versioning 2.0.0). HTTP APIs often carry only a major version in the path, as the example does with /v1/ and /v2/, and treat everything compatible as unversioned evolution.
Signaling deprecation in the response
Documentation alone does not reach a client that nobody reads. Two HTTP header fields carry the signal in each response. RFC 9745 defines the Deprecation header, whose value is a structured-field date marking when the resource was or will be deprecated, and a deprecation link relation that points to human-readable documentation; it also states that a Sunset timestamp must not be earlier than the Deprecation timestamp (RFC 9745). RFC 8594 defines the Sunset header, a single HTTP-date indicating when the resource is expected to become unresponsive. It says clients should treat Sunset timestamps as hints, since the resource may become unavailable earlier or later, and it notes the field can leak sensitive information such as expiration dates (RFC 8594).
The last line of the example’s output shows the same read through the deprecated /v1/ path. The body is unchanged, and the response now carries a Deprecation value of @1717200000, which is 1 June 2024 00:00 UTC, a Sunset of 31 December 2024, and a link to migration documentation. All three values are invented for the example. The Sunset date is later than the Deprecation date, as RFC 9745 requires. The headers change nothing about the response’s behavior, so they only help if someone reads them. Clients and tools need to log them, owners need to receive them, and the platform needs to count how many requests still arrive on the deprecated path, by tenant and credential. Kubernetes’ approach combines three of these signals, a warning header, an audit annotation, and a metric, and the combination is the point: the platform sees its remaining consumers directly and does not wait for complaints.
Retirement is a project with its own dependency discovery and communication, covered in Retiring Platform Capabilities Without Breaking Consumers. The deprecation entry makes the key distinction: deprecation is a warning with a replacement and a timeline, and removal is a separate, later change. Whether consumers actually moved, and what friction slowed them, is a measurement question taken up in Measuring Platform Adoption and Developer Friction.
Where this design fails
- Idempotency without atomicity. The example records the result in the same call as the effect, which a single thread allows. Concurrent requests with one key need a real uniqueness mechanism, and a crash between the effect and the record needs a design too.
- Isolation by convention. The example scopes by tenant in code. A forgotten tenant filter in one new endpoint reintroduces the leak, so cross-tenant tests belong in the standard test suite for every endpoint.
- Credentials that outlive their purpose. Extension tokens tend to accumulate. Track owner, scope, and last use for each, and expire what nobody claims.
- Fail-open extensions. A policy webhook that fails open can silently stop enforcing. If a control is mandatory, fail closed and invest in making the webhook highly available, and monitor its timeouts.
- Deprecation nobody sees. Headers and warnings reach only clients that log them. Measure remaining use directly and contact owners, because a sunset date set without usage data is a guess.
- Semantic drift under a stable shape. A changed meaning passes every structural check. Review field semantics in the change process and test behavior with consumer-driven checks like the one in the golden path article.
- One version forever. If no process ever retires versions, the platform supports every mistake it has ever made. Budget for version removal as part of the API’s cost.
A build order
A proposal for the sequence, not a standard:
- Write the resource model, mark each resource declared or operation-based, and give long-running operations a durable handle with step-level status.
- State the lifecycle stages with their promises and numeric windows, and name the owner with authority to enforce them.
- Derive the tenant from the credential, scope names, keys, and caches by tenant, and add cross-tenant tests for every endpoint.
- Require idempotency keys on creating calls, define scope, retention, and reuse errors, and implement an atomic claim before you expect concurrent retries.
- Require preconditions on updates and return typed problem details.
- Give every extension its own scoped credential, set failure policies and timeouts for request-path extensions, and test revocation.
- Publish tolerance rules for clients, classify every change before release, and run versions in parallel with conversion that round-trips.
- Send deprecation signals in responses, count remaining use, and set the sunset date from the usage data.
A platform API is a promise made to people you may never meet. Each decision above, from tenant scoping to the sunset header, either limits what the promise can break or makes the breakage visible early enough to repair.
References
- Kubernetes, Custom Resources
- Google AIP-151, Long-running operations
- Kubernetes, Deprecation Policy
- Kubernetes, Multi-tenancy
- OWASP, Authorization Cheat Sheet
- Stripe, Idempotent requests
- Amazon Builders’ Library, Making retries safe with idempotent APIs
- IETF Datatracker, The Idempotency-Key HTTP Header Field (Internet-Draft)
- RFC 9110, HTTP Semantics (If-Match)
- RFC 6585, Additional HTTP Status Codes (428)
- Kubernetes, API Concepts
- RFC 9457, Problem Details for HTTP APIs
- Kubernetes, Dynamic Admission Control
- Backstage, Permission Concepts
- Google AIP-180, Backwards compatibility
- Martin Fowler, Consumer-Driven Contracts: A Service Evolution Pattern
- Kubernetes, Versions in CustomResourceDefinitions
- Semantic Versioning 2.0.0
- RFC 9745, The Deprecation HTTP Response Header Field
- RFC 8594, The Sunset HTTP Header Field
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
