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

  1. The timeline of an API in production
  2. Day one: what you watch and what is normal
  3. Incident management: severities, mitigation and communication
  4. The blameless post-mortem (complete document)
  5. Listening to your consumers
  6. Contract debt
  7. Adding without breaking: a catalogue of safe changes
  8. When a v2 is due and how to migrate in 12 months
  9. Retiring features and the cost of what almost nobody uses
  10. Continuous maintenance: dependencies, secrets, SLOs and cost
  11. API inventory and zombie APIs
  12. Governance with several teams
  13. Common mistakes, exercises and conclusion

  1. 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.


  1. 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 -20

The 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.


  1. 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:

  1. 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.
  2. Preserve the evidence: capture traces, the Aroma-Trace-Id of failed requests, EXPLAIN output, metrics for the interval.
  3. 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.


  1. 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.


  1. 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.


  1. 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 roast was 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 tastingNotescuppingNotes 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.


  1. 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.


  1. When a v2 is due and how to migrate in 12 months

The 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 (tastingNotes on 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 /orders resource).
  • 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 codetype (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.


  1. 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 Coffee resource 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.


  1. 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:latest

The 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.


  1. 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.


  1. 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 oasdiff detects 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 v1 by calendar without looking at per-version metrics. A forgotten consumer always turns up. Metrics override the calendar; an announced Sunset is never brought forward, but it can be pushed back.
  • Launching a v2 out 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 Sunset brought 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:

  1. Adding the certifiedOrigin field (boolean) to the Coffee representation.
  2. Adding the value filter to the roast enum in responses.
  3. Changing the default limit of GET /v1/coffees from 20 to 50.
  4. Starting to reject a negative priceMin with 422 invalid_data.
  5. 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) total as a decimal in euros. It is real and serious (rounding on money), but it is solved by adding: publish totalCents as a new field, document it as preferred, deprecate total in the documentation and measure its use. It does not require a version.
  • (b) GET /invoices with no pagination. It can be paginated in a backwards-compatible way: accept limit/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_id in snake_case. It is aesthetic discomfort. Solution: also emit invoiceId (both fields coexist), document the new one, and remove the old one when the usage metrics allow it or when a v2 motivated 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

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved