In 06-02 we compared the design of Aroma Store with that of CafeSocial and closed with an uncomfortable thesis: there is no universal REST design. Now comes the last matter, the one that separates a well-designed API from an API that is still alive three years later: what happens after you publish it. Because on launch day the API is yours; from the first integration onwards it belongs to your consumers, and every field you return becomes a promise. This lesson walks through the first three years of the Aroma Store API as a timeline with concrete milestones: the first incident, the post-mortem of the Christmas outage, the contract debt that keeps piling up, the migration to v2 with its twelve-month schedule and the silent maintenance work nobody sees but which holds up everything else.
Contents
- The timeline of an API in production
- Day one: what you watch and what is normal
- Incident management: severities, mitigation and communication
- The blameless post-mortem (complete document)
- Listening to your consumers
- Contract debt
- Adding without breaking: a catalogue of safe changes
- When a
v2is due and how to migrate in 12 months - Retiring features and the cost of what almost nobody uses
- Continuous maintenance: dependencies, secrets, SLOs and cost
- API inventory and zombie APIs
- Governance with several teams
- Common mistakes, exercises and conclusion
- The timeline of an API in production
It helps to see the full cycle before going into each milestone. These are the moments that marked the first three years of https://api.aromastore.example/v1.
graph LR
A["Month 0<br/>v1 launch<br/>SPA + panel"] --> B["Month 2<br/>Aroma Mobile<br/>1st S3 incident"]
B --> C["Month 5<br/>SwiftShip<br/>HMAC webhooks"]
C --> D["Month 7<br/>Christmas outage<br/>34 min - post-mortem"]
D --> E["Month 11<br/>Contract debt<br/>register"]
E --> F["Year 2 - month 14<br/>CataBox OAuth<br/>+ per-client analytics"]
F --> G["Year 2 - month 18<br/>Decision: v2"]
G --> H["Year 2 - month 20<br/>Announcement + migration<br/>guide"]
H --> I["Year 3 - month 26<br/>Scheduled<br/>brownouts"]
I --> J["Year 3 - month 32<br/>v1 switched off<br/>410 retirement"]
None of these milestones is a new project: they are all maintenance. And in an API with several external consumers, maintenance consumes considerably more cumulative effort than the initial build.
- Day one: what you watch and what is normal
In month 0 the API is published with two in-house consumers: the SPA https://aromastore.example and the internal panel. All the instrumentation we set up in 04-07 (pino for structured logs, prom-client for metrics, traces with OpenTelemetry, /health/live and /health/ready) stops being an exercise and becomes the only place you can look.
The four signals you watch from minute one are the classic ones for a request-response service:
| Signal | What it measures | Normal in month 0 | Alert if |
|---|---|---|---|
| 5xx error rate | internal_error, service_unavailable |
< 0.1 % of requests | > 0.5 % for 5 min |
| p95 latency | Response time per route | 120–180 ms on GET /coffees |
> 400 ms for 10 min |
| Saturation | SQLite connections, memory, event loop | Stable | Event loop lag > 100 ms |
| Traffic | Requests per minute and per consumer | Grows smoothly | A 5× jump with no known campaign |
And there is a fifth signal almost nobody looks at on day one which turns out to be the most informative: the distribution of the 4xx.
# Top client errors of the last 24 h, grouped by catalogue code
# (pino's logs come out in JSON, so jq is all you need)
cat logs/api-*.log \
| jq -r 'select(.res.statusCode >= 400 and .res.statusCode < 500)
| "\(.res.statusCode) \(.error.code // "no_code") \(.req.url | split("?")[0])"' \
| sort | uniq -c | sort -rn | head -20The real output from the second day:
412 400 invalid_parameter /v1/coffees
118 401 not_authenticated /v1/orders
97 404 coffee_not_found /v1/coffees/{id}
31 409 version_conflict /v1/coffees/{id}What is normal and what is not. The 401s are normal: the SPA retries with an expired token and renews it. The 404s on /coffees/{id} are too: there are old indexed links out there. What is not normal is 412 invalid_parameter on GET /v1/coffees: that is not a clumsy client, it is a badly explained contract. Inspecting the error details reveals the pattern: clients are sending ?roast=Medium with an initial capital, and our enum only accepts light|medium|dark. The documentation said so; the error message did not. The message was fixed —not the validation— and the 400s dropped to 20 a day.
Day one rule: a repeated 4xx error does not accuse the client, it describes a design or documentation defect in your API.
The first incident (month 2). With the launch of Aroma Mobile the first 429s appear. The app polls the cart every 5 seconds and the rate limiting from 04-04 starts returning rate_limit_exceeded with Retry-After. Diagnosis took twenty minutes because the Aroma-RateLimit-Remaining headers were not being written to the logs. The solution was not to raise the limit: it was to add an ETag to the cart (we already had one on /coffees, see 04-05) so that the polling would answer 304 Not Modified at almost no cost, and to publish in the integration guide that the recommended interval was 30 seconds.
- Incident management: severities, mitigation and communication
Detection: the error budget is in charge
In 04-07 we set an availability SLO of 99.9 % monthly for the read routes. That 0.1 % is 43 minutes of error budget per month. The budget is what turns a discussion of opinions ("is this serious?") into an arithmetic decision: if 60 % of the monthly budget has been consumed in three days, feature deployments are frozen and the team works on reliability until the rate recovers.
Alerts do not fire on instantaneous thresholds but on the burn rate of the budget: consuming the budget 14 times faster than is sustainable for 5 minutes is an immediate page; 6 times faster for an hour is a normal one.
Severity levels
| Sev | Definition | Example at Aroma Store | Response | Communication |
|---|---|---|---|---|
| S1 | API down or purchases impossible for everybody | POST /orders/{id}/payment returns 500 for 100 % of calls |
Immediate on-call, incident room | Status page in < 15 min + notice to partners |
| S2 | Severe degradation or critical functionality broken for one consumer | Aroma Mobile gets version_conflict on every update |
Immediate on-call during extended hours | Status page + email to the affected consumer |
| S3 | Partial degradation with no data loss | p95 of /coffees at 900 ms; slow searches |
Next working day | Note in the developer portal |
| S4 | Minor defect, contract not honoured with no impact | Retry-After missing on one specific 429 |
Prioritised backlog | Changelog |
Mitigate before you diagnose
It is the hardest rule for a technical team to internalise, because curiosity pushes towards the why. During an incident, the right order is:
- Restore the service with whatever it takes: roll back the last deployment, turn off the feature flag (05-04), send traffic back to the previous colour in the blue-green, degrade a feature.
- Preserve the evidence: capture traces, the
Aroma-Trace-Idof failed requests,EXPLAINoutput, metrics for the interval. - Afterwards, work out the root cause calmly.
A deployment rolled back in 4 minutes costs you a post-mortem; a deployment debugged live for 40 minutes costs you the quarter's error budget.
What you tell your consumers
With external consumers —SwiftShip and CataBox— saying nothing is worse than being wrong. The pattern we adopted: an entry on the status page within 15 minutes even if you know nothing yet ("we are investigating errors when creating orders"), updates every 30 minutes, and a final note with a link to the post-mortem once it is published. No internal infrastructure details; yes to the observable impact and advice on retrying.
- The blameless post-mortem
Month 7, 18 December. The Christmas campaign multiplies traffic by six. Two weeks earlier the ?origin= filter had been added to GET /v1/coffees —a textbook backwards-compatible change, three lines of code— but with no index on the corresponding column. With the catalogue grown and campaign traffic, the database saturates and the API becomes unreachable for 34 minutes.
This is the document as it ended up in docs/incidents/. Notice what it does not contain: any name attached to the cause.
# Post-mortem INC-041: database saturation caused by an unindexed filter
- **Status:** closed
- **Severity:** S1
- **Date:** 18 December, 19:42 - 20:16 (CET)
- **Impact duration:** 34 minutes
- **Author:** API platform team
- **Reviewed by:** product, support, security
## Summary
A filter added two weeks earlier (`GET /v1/coffees?origin=`) was causing a full scan
of the `coffees` table. With Christmas campaign traffic (6× the average) the queries
exhausted the connection pool and the whole API stopped responding, including routes
that did not use that filter.
## Timeline (CET)
| Time | Event |
|---|---|
| 04/12 10:15 | The `origin` filter is deployed on `/v1/coffees`. No index. No load test. |
| 18/12 19:31 | The Christmas newsletter goes out with links to `?origin=ethiopia`. |
| 18/12 19:38 | p95 of `/v1/coffees` goes from 160 ms to 2.4 s. Nobody is watching. |
| 18/12 19:42 | 14× burn rate alert. Measurable impact begins. |
| 18/12 19:44 | On-call acknowledges. The incident room is opened. |
| 18/12 19:47 | First note published on the status page ("investigating"). |
| 18/12 19:53 | The last deployment (17/12) is ruled out as the cause: rolling it back changes nothing. |
| 18/12 20:01 | The traces show that 88 % of the time is spent in a single query. |
| 18/12 20:04 | **Mitigation:** the `origin` filter is switched off with the feature flag and
`400 invalid_parameter` is returned temporarily for that parameter. |
| 18/12 20:09 | p95 latency returns to 210 ms. The pool recovers. |
| 18/12 20:16 | End of impact. Resolution note on the status page. |
| 18/12 21:30 | The index is created in a low-load window and the filter is switched back on. |
## Measured impact
- 34 minutes of partial-to-total unavailability (79 % of requests with a 5xx or a timeout).
- 41,200 failed requests; 218 attempts at `POST /v1/orders` left uncompleted.
- 96 orders not closed during the window; 61 recovered by themselves through client
retries thanks to `Idempotency-Key` (there were no duplicate charges).
- Monthly error budget consumed: 79 % (34 min out of the 43 min available).
- 7 support tickets and 1 notice from SwiftShip about delayed shipment webhooks.
## Root cause
The query generated by the `origin` filter had no index and performed a sequential
scan of `coffees`. Under high concurrency, each query held its connection long enough
to exhaust the pool, so that requests unrelated to the filter (`/v1/orders`,
`/v1/customers`) also ended up waiting.
Contributing cause: the review of the change focused on the contract (parameter name,
validation, documentation in `openapi.yaml`) and not on its execution plan. There was
no automated check that required it.
## What failed in detection
- The degradation started at 19:38 and the alert fired at 19:42: four minutes lost
because the latency alert only looked at the global aggregate, not per route.
- There was no alert on connection pool saturation, which was the earliest and most
unambiguous signal.
- The newsletter send had not been announced to the platform team.
## What we did well
- The feature flag allowed us to mitigate without deploying code.
- Idempotency prevented duplicate charges on the retries.
- The status page was updated before we had a diagnosis.
## Actions
| # | Action | Type | Owner | Deadline |
|---|---|---|---|---|
| 1 | Create the `idx_coffees_origin` index and validate it with EXPLAIN QUERY PLAN | Corrective | Data team | 19/12 (done) |
| 2 | p95 latency alert **per route**, not only aggregated | Detection | Platform | 09/01 |
| 3 | Alert on connection pool saturation at 80 % | Detection | Platform | 09/01 |
| 4 | Add to the review checklist: "every new filter declares its index" | Preventive | API governance | 15/01 |
| 5 | Automated load test in CI for listing routes | Preventive | Platform | 31/01 |
| 6 | Shared marketing campaign calendar with platform | Organisational | Product | 15/01 |
| 7 | Publish an incident summary in the developer portal | Communication | Support | 22/12 (done) |
## What is NOT an action
"Be more careful when reviewing" is not an action: it is neither verifiable nor does
it leave a trace. If an action cannot be closed with a link to a commit, a board or a
document, it does not go in this table.The verification of action 1, so that it is clear what gets checked:
-- Before: full table scan
EXPLAIN QUERY PLAN
SELECT * FROM coffees WHERE origin = 'ethiopia' ORDER BY name LIMIT 20;
-- SCAN coffees
CREATE INDEX idx_coffees_origin ON coffees (origin, name);
-- After: index search
EXPLAIN QUERY PLAN
SELECT * FROM coffees WHERE origin = 'ethiopia' ORDER BY name LIMIT 20;
-- SEARCH coffees USING INDEX idx_coffees_origin (origin=?)Blameless does not mean ownerless. The actions have an owner and a deadline; the cause has no owner. The person who added the filter without an index did what the system allowed them to do: there was no checklist, no load test, no alert. The system failed, and the system is what gets fixed.
- Listening to your consumers
From month 9 onwards, the question stops being "does it work?" and becomes "what are they actually doing with it?". Three sources:
Usage analytics per endpoint and per client. Every JWT carries a client_id (04-02). The request metric is labelled with that identifier, but carefully, because of cardinality: labelling by route with parameters (/v1/coffees/cof_001) creates one time series per coffee and blows up Prometheus's memory. You label by route template and by client, which are small, bounded sets.
// observability/requests.js — cardinality kept under control on purpose
import client from 'prom-client';
const requests = new client.Counter({
name: 'aroma_requests_total',
help: 'Requests served by the API',
// route = TEMPLATE (/v1/coffees/:id), never the concrete path.
// client = the JWT's client_id, a closed set of ~6 consumers.
// version = v1 | v2, essential for the migration (section 8).
labelNames: ['method', 'route', 'status', 'client', 'version'],
});
export function countRequest(req, res) {
requests.inc({
method: req.method,
route: req.route?.path ?? 'unknown', // template, not the real URL
status: res.statusCode,
client: req.auth?.clientId ?? 'anonymous', // bounded label
version: req.baseUrl.startsWith('/v2') ? 'v2' : 'v1',
});
}Which fields nobody uses. A REST API returns the full representation, so you do not know what the client reads… unless you ask. Two cheap techniques: (a) measure the use of the projection parameter if you have one (?fields=), and (b) ask directly in the annual integrators' survey. At Aroma Store that is how we discovered that tastingNotes was only consumed by the SPA and CataBox, and that the _links.self field on each collection element was used by absolutely nobody: the clients built the URLs by concatenation. An uncomfortable data point that went into the HATEOAS self-critique of 06-01.
Recurring 4xx errors per client. It is the best documentation to-do list in existence:
| Repeated error | Client | What it really means | Action taken |
|---|---|---|---|
invalid_parameter on sort |
CataBox | The -priceEuros separator was not in the examples |
Example added to openapi.yaml |
version_conflict on PUT /coffees/{id} |
Internal panel | It was not resending the ETag after a failure |
Note in the guide + more explicit details |
insufficient_stock at payment |
Aroma Mobile | The stock warning comes far too late | Design debt (see section 6) |
Mass not_authenticated at 03:00 |
SwiftShip | Badly scheduled token renewal | Direct email to the partner |
And the support channel: an [email protected] address that reaches a human, with a public commitment to reply within 2 working days. Every ticket is tagged as a defect, documentation or feature request; documentation ones are closed by editing the openapi.yaml, never by replying only over email.
- Contract debt
Month 11. The team makes a list of "things we would fix if we were starting today" and realises that none of them can be fixed. That is contract debt: published decisions that are no longer the best ones, but which hold up real consumers.
It accumulates for three reasons, and none of them is negligence:
- The domain changes. When
roastwas designed with three values, filter roast did not exist in the catalogue. - The standard changes, or you get to know it better.
application/problem+json(RFC 9457) existed, but we opted for a format of our own; today it would be the obvious choice. - You get it 80 % right. The
{"data": [...], "total": n}envelope worked well, but it makes every response more expensive and confuses clients that expect a bare array.
In 06-01 we already criticised four decisions. Now they have a price:
| Debt | Desired change | Why it cannot be done in v1 |
Cost of living with it |
|---|---|---|---|
| Unclear name | tastingNotes → cuppingNotes |
Breaks all 5 consumers | Low: it confuses newcomers |
| Envelope | Drop data / use only Link |
Breaks all collection parsing | Medium: duplicated code in clients |
| Error format | Adopt problem+json |
Changes the Content-Type and the shape of the body |
Medium: friction with standard libraries |
| Reviews with two routes | Keep only /coffees/{id}/reviews |
The SPA uses /reviews?coffeeId= |
High: two paths to maintain and cache |
| Late stock warning | Validate stock when adding to the cart | Changes the semantics of POST /carts/{id}/items |
High: recurring support |
The artefact that stops this being forgotten is the contract debt register, versioned alongside the code in docs/contract-debt.yaml. It is a living document: it is reviewed every quarter and it is the raw material for the decision about v2.
# docs/contract-debt.yaml — reviewed every quarter
debts:
- id: CD-004
title: "The insufficient stock warning arrives at payment, not in the cart"
origin: "Post-mortem INC-041 and 38 support tickets"
affected_consumers: [store-spa, aroma-mobile]
impact: high
desired_solution: >
Validate stock in POST /carts/{id}/items and return 409 insufficient_stock
at that moment, keeping the final validation at payment.
breaks_contract: true # changes the status code in a previously valid case
v2_candidate: true
created: "year 1, month 11"
reviewed: "year 2, month 18"
- id: CD-007
title: "Our own error format instead of application/problem+json"
affected_consumers: [all]
impact: medium
breaks_contract: true
v2_candidate: true
note: >
Possible mitigation without breaking anything: content negotiation. If the client
sends Accept: application/problem+json we return that format; otherwise, our own.Notice the note on CD-007: part of the debt can be paid off without breaking anything if you think in terms of content negotiation (02-05). Not all debt demands a new version.
- Adding without breaking: a catalogue of safe changes
Before contemplating a v2, you have to exhaust what can be done inside v1. We saw the general rule in 02-07: adding is safe, removing and changing meaning are not. The detail matters far more than it looks.
| Change | Does it break? | Backwards-compatible solution |
|---|---|---|
New certifiedOrigin field on Coffee |
No* | Add it as optional and document it; clients that do not know it ignore it |
New endpoint /coffees/{id}/batches |
No | Publish and document |
New filter ?certified=true |
No | Optional, with a default = current behaviour |
New filter value in the roast enum (response) |
Yes, in practice | Introduce it with prior notice; clients with an exhaustive switch will fail |
New value accepted in roast (request) |
No | Widening input validation is safe |
| Tightening an existing validation | Yes | A warning phase: log, do not reject; then reject |
Changing the default limit from 20 to 50 |
Yes | Do not change it; or change it only for new clients |
Renaming tastingNotes |
Yes | Duplicate the field + deprecate the old one, or wait for v2 |
| Removing a field | Yes | Only in v2 |
Changing 200 to 202 on an operation |
Yes | Only in v2 |
| Making an optional input field mandatory | Yes | Only in v2 |
* The asterisk on the new field. "Adding a field breaks nothing" is only true if the clients are tolerant readers: if they ignore what they do not know. A client that validates the response against a strict schema with additionalProperties: false, or that uses a language that fails when deserialising unknown fields, breaks on a new field. That is why the Aroma Store integration guide says this on its first page:
// Tolerance contract published in the integration guide.
// This is how a client should read the response of GET /v1/coffees/cof_001
const coffee = await response.json();
// GOOD: the known fields are read and the rest is ignored.
const view = {
id: coffee.id,
name: coffee.name,
priceEuros: coffee.priceEuros,
// An unknown value in an enum: degrade, do not explode.
roast: ['light', 'medium', 'dark'].includes(coffee.roast) ? coffee.roast : 'other',
};
// BAD: breaks the moment we add certifiedOrigin.
// const { id, name, priceEuros, ...rest } = coffee;
// if (Object.keys(rest).length > 0) throw new Error('Unknown field');The dangerous case: tightening a validation. In month 14 it was discovered that tastingNotes accepted text of any length and somebody had stored 40 KB in it. The temptation is to add .max(500) to the Zod schema (03-02) and deploy. That turns previously valid requests into 422 invalid_data: it is a breaking change even though it does not touch a single field name. The correct procedure has two stages.
// Phase 1 (weeks 1-6): warning mode. Nothing is rejected, we measure who would fail.
const coffeeSchema = z.object({
name: z.string().min(1).max(120),
tastingNotes: z.string(), // no limit yet
// ...
}).superRefine((data, ctx) => {
if (data.tastingNotes.length > 500) {
// Logged along with the client, so we can warn them one by one.
log.warn({
event: 'future_validation_violated',
rule: 'tastingNotes_max_500',
length: data.tastingNotes.length,
client: ctx.path,
}, 'Request that will be rejected from 1 March onwards');
warningCounter.inc({ rule: 'tastingNotes_max_500' });
}
});
// Phase 2 (week 7, only if the counter has been at zero for 14 days):
// tastingNotes: z.string().max(500)If after six weeks the counter is still climbing, phase 2 is not deployed: you call the client. The date moves; the contract is not broken by surprise.
- When a
v2 is due and how to migrate in 12 months
v2 is due and how to migrate in 12 monthsThe right answer is almost always "not yet"
A v2 is not an achievement, it is an invoice: two codebases or two translation layers, two test suites, two sets of documentation, two versions of the openapi.yaml, and consumers who will take months to move. Honest criteria:
Signs that a v2 is NOT due:
- The change can be made by adding (section 7).
- It annoys the team but not the consumers (
tastingNoteson its own justifies nothing). - It is a documentation problem dressed up as a design problem.
- There are fewer than three high-impact debts accumulated.
Signs that it IS due:
- Several high-impact debts that can only be resolved by breaking, and which cause incidents or recurring support.
- The domain model has genuinely changed (Aroma Store started selling subscriptions, and a recurring order does not fit the current
/ordersresource). - Non-negotiable security changes.
- The cost of maintaining the workarounds exceeds the cost of migrating.
Month 18. Aroma Store decides on the v2 with four high-impact debts and a new domain (subscriptions). An ADR is written in docs/decisions/0031-launch-v2.md with the rejected alternative (keep patching v1 with negotiated problem+json and a /subscriptions resource hanging off v1) and why it is rejected.
A 12-month schedule
Recall the published policy: versioning only in the path, and v1 and v2 coexist for at least 6 months. In practice, for an API with external partners, six months is the legal minimum and twelve is the reasonable minimum.
| Month | Milestone | What the consumer sees |
|---|---|---|
| M0 | Announcement + migration guide + v2 in beta |
Email, portal, changelog, test environment |
| M1 | v2 stable in production |
Both versions running |
| M1 | v1 marked as deprecated |
Deprecation, Sunset, Link rel="successor-version" headers |
| M2–M5 | Hand-holding | Priority support for integrators, examples, technical sessions |
| M6 | First metrics cut | Internal report: who is still on v1 |
| M7 | Direct contact with the stragglers | A phone call, not an automated email |
| M9 | Brownout 1: 30 min of 410 on v1 |
Announced 2 weeks ahead; low-load window |
| M10 | Brownout 2: 2 h of 410 |
Announced 2 weeks ahead |
| M11 | Brownout 3: 8 h of 410 |
Announced 2 weeks ahead |
| M12 | v1 switched off for good |
Permanent 410 Gone with api_version_retired |
| M12+3 | The v1 code is deleted |
— |
The migration guide: the equivalence table
It is the document that decides whether the migration takes the consumer two days or two months. No prose: literal equivalences.
v1 |
v2 |
Note |
|---|---|---|
GET /v1/coffees → {"data":[…],"total":n} |
GET /v2/coffees → {"items":[…],"pagination":{…}} |
The exact total becomes optional |
tastingNotes |
cuppingNotes |
Renamed |
roast: light|medium|dark |
roast: light|medium|dark|filter |
New value |
Our own error {"error":{…}} |
application/problem+json |
code → type (a catalogue URI) |
GET /v1/reviews?coffeeId= |
GET /v2/coffees/{id}/reviews |
A single route |
POST /v1/carts/{id}/items (no stock check) |
The same, but it may return 409 insufficient_stock |
Early warning |
| — | GET /v2/subscriptions |
New resource |
?offset= |
?cursor= |
offset is accepted for 6 months in v2 |
The deprecation headers in action
Picking up 02-07, this is how v1 responds from month 1 onwards:
GET /v1/coffees?origin=ethiopia HTTP/1.1
Host: api.aromastore.example
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Deprecation: @1836345600
Sunset: Sat, 12 Jun 2027 00:00:00 GMT
Link: <https://api.aromastore.example/v2/coffees?origin=ethiopia>; rel="successor-version",
<https://docs.aromastore.example/migration-v2>; rel="deprecation"; type="text/html"
Aroma-Trace-Id: trc_9f2a41c8// middleware/deprecation.js — applied to the whole /v1 router
const SUNSET = new Date('2027-06-12T00:00:00Z');
const DEPRECATION_UNIX = Math.floor(new Date('2026-06-12T00:00:00Z').getTime() / 1000);
export function deprecateV1(req, res, next) {
// Deprecation: when it WAS declared obsolete (IMF-date format or an @unix stamp).
res.set('Deprecation', `@${DEPRECATION_UNIX}`);
// Sunset: when it will stop responding. It is a promise; it is never brought forward.
res.set('Sunset', SUNSET.toUTCString());
res.append('Link',
`<https://api.aromastore.example/v2${req.path}>; rel="successor-version"`);
res.append('Link',
'<https://docs.aromastore.example/migration-v2>; rel="deprecation"; type="text/html"');
next();
}Knowing who is still on v1
With the version label on the counter from section 5, the question answers itself:
# Consumers still using v1 in the last 7 days, ordered by volume
curl -sG "http://prometheus.internal:9090/api/v1/query" \
--data-urlencode 'query=topk(10, sum by (client) (increase(aroma_requests_total{version="v1"}[7d])))' \
| jq -r '.data.result[] | "\(.metric.client)\t\(.value[1] | tonumber | floor)"'catabox 412803 erp-integration 18744 <- nobody knew it existed (see section 11) aroma-mobile 2210 <- an old version of the app, never updated
That second result is the reason you never switch off a version by calendar without looking at the metrics: a forgotten consumer always turns up. And the third is a reminder that with mobile apps you do not control when the user updates: the old app will stay alive for months.
Brownouts
A brownout is a brief, announced switch-off of v1: during the window, every request receives a 410. Its function is not technical, it is psychological: it turns a distant date into a real incident in the consumer's environment, which is the only thing that shifts priorities.
// middleware/brownout.js — brief scheduled switch-offs of v1
const WINDOWS = [
{ from: '2027-03-10T09:00:00Z', to: '2027-03-10T09:30:00Z' }, // 30 min
{ from: '2027-04-14T09:00:00Z', to: '2027-04-14T11:00:00Z' }, // 2 h
{ from: '2027-05-12T07:00:00Z', to: '2027-05-12T15:00:00Z' }, // 8 h
];
export function brownoutV1(req, res, next) {
const now = Date.now();
const window = WINDOWS.find(w =>
now >= Date.parse(w.from) && now < Date.parse(w.to));
if (!window) return next();
// Retry-After says when v1 comes back: during a brownout it DOES come back.
res.set('Retry-After', String(Math.ceil((Date.parse(window.to) - now) / 1000)));
res.status(410).json({
error: {
code: 'api_version_retired',
message: 'Scheduled switch-off of /v1. Migrate to /v2 before 12/06/2027.',
details: [{ field: 'version', value: 'v1', successor: '/v2' }],
},
});
}And the final switch-off, now without Retry-After:
GET /v1/coffees HTTP/1.1
Host: api.aromastore.example
HTTP/1.1 410 Gone
Content-Type: application/json; charset=utf-8
Link: <https://api.aromastore.example/v2/coffees>; rel="successor-version"
{
"error": {
"code": "api_version_retired",
"message": "Version v1 was retired on 12/06/2027. Use /v2.",
"details": [
{ "field": "version", "value": "v1" },
{ "field": "guide", "value": "https://docs.aromastore.example/migration-v2" }
]
}
}410 Gone and not 404: the difference communicates that the resource existed and was removed on purpose, and it stops a client thinking they have got the route wrong.
- Retiring features and the cost of what almost nobody uses
Year 2. The endpoint GET /v1/coffees/{id}/pairings, added in month 4 at marketing's request, receives 40 requests a month from a single client. Its cost is not zero:
- It appears in
openapi.yaml, so it has to be documented and linted with Spectral. - It has tests that run on every CI build and that sometimes fail because of test data.
- It has a table with its migration, which has to be dragged along on every schema change.
- It blocks decisions: any refactor of the
Coffeeresource has to take it into account. - It takes up mental space in every design review.
Retiring one specific feature follows the same protocol as a version, in miniature: announcement, Deprecation/Sunset on that route only, contact with the single consumer, and a 410. It took three months. What you must never do is delete it because "almost nobody uses it": that "almost" is a company that depends on it.
- Continuous maintenance: what nobody sees
Recurring work that produces no features and without which the API degrades on its own.
Dependencies and CVEs. An automated weekly audit in the ci.yml from 05-04:
npm audit --audit-level=high # fails the pipeline on high or critical
npm outdated # weekly report, non-blocking
docker scout cves aromastore-api:latestThe agreed policy: a critical, exploitable vulnerability in a dependency, patched within 48 h; high, within 7 days; the rest, in the monthly maintenance window.
Node version. Node 20 reaches end of support and you have to jump to the next LTS. It is an invisible change for the consumer and a dangerous one for you: it is done by canary (05-04), with 5 % of the traffic for 48 h, comparing p95 and error rate between the two groups. It goes into the calendar before support expires, not after.
Rotating secrets and signing keys. Three different clocks:
| Secret | Rotation | How it is rotated without downtime |
|---|---|---|
| JWT signing key | Every 90 days | Two active keys with kid; sign with the new one, verify both |
| HMAC secret for webhooks to SwiftShip | Every 180 days | Dual signature for 14 days (Aroma-Signature and Aroma-Signature-Next) |
| CataBox's OAuth credentials | Annually or after an incident | A 30-day overlap |
SLO review. Every six months. If the 99.9 % SLO has never been anywhere near breached in a year, either it is badly measured or it is too lax. If it is missed every month, either it is not achievable with the current architecture, or the error budget is being used as an excuse. At Aroma Store, the latency SLO for /coffees was tightened from 400 ms to 300 ms in year 2 after the indexing work.
Security review. An annual pass over the OWASP API Top 10 (04-01) against the real state of things: object-level authorisation on every new endpoint, consumption limits, data exposure. It is documented like any design review.
Infrastructure cost as a design signal. The API's bill is a design metric dressed up as a financial one. If an endpoint costs disproportionately much, there is almost always a contract decision behind it: GET /v1/orders with no mandatory pagination returning thousands of elements, no ETag on a heavily queried resource, or a client polling where there should be a webhook. Before scaling the infrastructure, review the contract: it comes out cheaper.
- API inventory and zombie APIs
In 04-02 we defined ownership for every API and in 05-06 we set up the developer portal with its inventory. Their real usefulness shows up right now: during the migration to v2 a consumer called erp-integration appeared that nobody remembered authorising. It existed, it worked and it was in production.
A zombie API is an API (or a version, or an endpoint) that still responds, consumes resources and presents attack surface, but has no identifiable owner. They are detected by cross-referencing three sources: the declared inventory, the real traffic metrics and the register of issued credentials. Any row that appears in one source and is missing from another is an alarm.
Every inventory entry must answer, as a minimum: who maintains it, who consumes it, which version is current, what retirement date it has if it is deprecated, and where its openapi.yaml lives. No owner, no deployment: it is the only way to stop the inventory ageing.
- Governance with several teams
Year 3. There is no longer one team, there are three: catalogue, orders and subscriptions. Without governance, in six months you have three APIs that look as if they came from three different companies: one with snake_case, another with plain-text errors, another paginating with page/size.
Aroma Store's governance rests on four pieces, all of them already familiar:
- The style guide (02-01) as a written standard, not as a recommendation. It is the reference that settles arguments in a review.
- The design review: before writing any code, the team presents the proposed fragment of
openapi.yaml. Ten minutes with two people from other teams save months of debt. The contract is reviewed, not the implementation. - Automated gates in CI (05-04/05-05): Spectral validates the style and
oasdiffdetects breaking changes. What is automated is not up for discussion, and that is precisely its virtue. - ADRs in
docs/decisions/: every structural decision with its context, alternatives and consequences. It exists so that in two years' time nobody "fixes" something that was that way on purpose.
# .github/workflows/ci.yml (extract) — contract gates
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Contract style (the style guide as code)
run: npx @stoplight/spectral-cli lint openapi.yaml --fail-severity=warn
- name: Breaking changes against the published version
run: |
git show origin/master:openapi.yaml > /tmp/openapi-published.yaml
npx oasdiff breaking /tmp/openapi-published.yaml openapi.yaml --fail-on ERR
# If the change IS breaking on purpose, it is approved with the
# "breaking-change-approved" label on the PR and a linked ADR. Never silently.And the complete human flow of a contract change:
sequenceDiagram
participant T as Team
participant R as Design review
participant CI as CI (Spectral + oasdiff)
participant C as Consumers
T->>R: Proposed change to openapi.yaml
R-->>T: Style guide, ADR if structural
T->>CI: Pull request
CI-->>T: Breaking change detected
alt Not breaking
CI->>C: Canary deployment + changelog
else Breaking
T->>R: ADR with alternatives
R-->>T: To the contract debt or to v2
end
C-->>T: Usage and support metrics
Common Mistakes and Tips
- Debugging live instead of mitigating. Every minute of diagnosis during an S1 is paid for out of the error budget. Roll back, turn off the flag, and investigate afterwards.
- Post-mortems that end in "lack of attention". If the action cannot be closed with a verifiable link, it is not an action. And if the document names a person as the cause, next time nobody will tell you what happened.
- Confusing "blameless" with "consequence-free". The actions have an owner and a deadline, and they are reviewed in the following month's retrospective.
- Believing that adding a field never breaks anything. It is only true with tolerant-reader clients. Publish that requirement in the integration guide from day one.
- Tightening validations with no warning phase. It is the breaking change that slips through most often because it does not touch the visible schema. Measure first, reject afterwards.
- Labelling metrics by concrete path or by
id. Cardinality explodes and you lose your observability exactly when you need it. Route templates and closed sets. - Switching off
v1by calendar without looking at per-version metrics. A forgotten consumer always turns up. Metrics override the calendar; an announcedSunsetis never brought forward, but it can be pushed back. - Launching a
v2out of internal discomfort. If the pain is felt only by your team, solve it internally. A new version is an invoice paid by every one of your consumers. - Treating 4xx as the client's fault. They are your documentation to-do list, sorted by impact.
- A final tip: publish the deprecation calendar somewhere stable and honour it even when it hurts. A
Sunsetbrought forward destroys more trust than a 34-minute incident.
Exercises
Exercise 1: classifying changes
For each proposed change to Aroma Store's v1, state whether it is backwards compatible, whether it is breaking, and what the correct strategy would be:
- Adding the
certifiedOriginfield (boolean) to theCoffeerepresentation. - Adding the value
filterto theroastenum in responses. - Changing the default
limitofGET /v1/coffeesfrom 20 to 50. - Starting to reject a negative
priceMinwith422 invalid_data. - Adding the optional filter
?certified=true.
Exercise 2: deciding on the v2
An internal invoicing API has accumulated these debts: (a) the total field is in euros as a decimal number and causes rounding errors; (b) GET /invoices does not paginate and returns up to 4,000 elements; (c) the name invoice_id uses snake_case while the rest of the API uses camelCase. There are only two consumers, both internal. Would you launch a v2? Justify it with criteria, not with taste.
Exercise 3: post-mortem actions
A Friday afternoon deployment introduced a bug in token renewal: for 18 minutes, every request from Aroma Mobile received 401 not_authenticated. It was detected because a user posted on social media; nobody on the team saw any alert. Write five actions for the post-mortem, each with a type (corrective, detection, preventive, organisational, communication) and a verifiable closing criterion.
Solutions
Exercise 1
| # | Change | Verdict | Strategy |
|---|---|---|---|
| 1 | certifiedOrigin |
Backwards compatible* | Add it as optional and document it. The asterisk: if any consumer validates with additionalProperties: false, it does break. Check beforehand in the integration guide and give notice in the changelog |
| 2 | filter value in responses |
Breaking in practice | Any client with an exhaustive switch on roast will fail. Announce it 30 days ahead, document the new value, and first accept it in requests (safe) before emitting it in responses |
| 3 | Default limit 20 → 50 |
Breaking | It changes the page size the client receives without asking for it; it can overflow interfaces and multiply the load. Do not change it in v1. Alternative: document limit better and leave the new default for v2 |
| 4 | Rejecting a negative priceMin |
Breaking | Even though it is "more correct", previously accepted requests start failing. A warning phase measuring with superRefine for 6 weeks; if the counter reaches zero, reject. And 400 invalid_parameter, because it is a query parameter, not a body |
| 5 | ?certified=true filter |
Backwards compatible | Optional; absent = current behaviour. Add it to openapi.yaml, with its index in the database (the lesson of INC-041) |
Exercise 2
No, not yet. Debt by debt:
- (a)
totalas a decimal in euros. It is real and serious (rounding on money), but it is solved by adding: publishtotalCentsas a new field, document it as preferred, deprecatetotalin the documentation and measure its use. It does not require a version. - (b)
GET /invoiceswith no pagination. It can be paginated in a backwards-compatible way: acceptlimit/offset, and as long as they are not sent, return the current behaviour. With two internal consumers, you can also negotiate a maximum limit with prior notice. It does not require a version. - (c)
invoice_idinsnake_case. It is aesthetic discomfort. Solution: also emitinvoiceId(both fields coexist), document the new one, and remove the old one when the usage metrics allow it or when av2motivated by something else comes along.
Besides, with two internal consumers the coordination cost is tiny compared with that of maintaining two versions. The applicable criterion: none of the three debts forces a break, therefore there is no v2. What is appropriate is to open three entries in the contract debt register with v2_candidate: true and review them every quarter.
Exercise 3
| # | Action | Type | Closing criterion |
|---|---|---|---|
| 1 | Fix the token renewal bug and deploy with a 5 % canary | Corrective | Linked commit + 24 h of canary with the 401 rate at baseline |
| 2 | Alert on the 401 rate per client, firing after 3 min above double the baseline |
Detection | Alert created and tested with a controlled injection in staging |
| 3 | Integration test of the complete renewal cycle (expired token → refresh → request) in CI | Preventive | A node:test + Supertest test running in ci.yml and failing if the fix is reverted |
| 4 | A policy freezing deployments on Fridays from 15:00 except for urgent fixes | Organisational | Rule published in the repository and an automated check in the pipeline |
| 5 | A note on the status page and a notice to affected Aroma Mobile users with a summary of the incident | Communication | Entry published with a link to the post-mortem |
Notice that action 2 is the most valuable: the bug lasted 18 minutes, but the real problem is that detection came from outside. An incident discovered by a user on social media is, above all, an observability failure.
Conclusion
Designing an API is a bounded exercise; maintaining it is an open-ended commitment. In this lesson we have walked through three years of the Aroma Store API and seen that almost all the work after launch consists of protecting the people who already trust you: watching the right signals from day one, mitigating before diagnosing when something breaks, writing post-mortems that fix the system instead of hunting for culprits, listening to what the 4xx and the usage analytics are telling you, recording contract debt instead of pretending it does not exist, exhausting everything that can be added without breaking, and —only when there is no alternative left— planning a v2 with its schedule, its Deprecation and Sunset headers, its brownouts and its final 410. On top of that comes the maintenance nobody applauds: CVEs, Node versions, key rotation, SLO review, an inventory with no zombies and a governance built on Spectral, oasdiff and ADRs that keeps things coherent once there is no longer a single team.
The underlying conclusion is simple and demanding at the same time: an API is a long-term commitment to whoever consumes it. Every published field is a promise, every announced Sunset is a contract, and the trust you build over three years can be lost with one "harmless" change deployed on a Friday afternoon. The quality of an API is not measured on launch day, but by how easily an integrator can keep working with it when nobody from the original team is still at the company.
That closes our run of case studies and analysis. Now it is your turn: in 06-04, Final Project: Design and Build Your Own RESTful API, you will apply everything you have learned in phases —contract design, implementation, security, documentation and deployment— to a domain of your own, with a clear rubric for assessing yourself. Everything we have done so far has been preparing you for that brief.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
