Changing a Live API

Which changes break clients, how to evolve additively, and how Deprecation and Sunset headers give integrators a deadline.

SyntaxDeprecation: @1688169599 · Sunset: Thu, 01 Apr 2027 00:00:00 GMT

What breaks a client

Removing or renaming a field. Changing a type — "42" to 42, a scalar to an object. Making an always-present field nullable. Tightening validation so previously accepted input is rejected. Changing the status code or error code for an existing failure. Changing default sort order or page size. Adding a required request field.

What only looks breaking: adding an optional response field, adding an optional request parameter, adding an endpoint, relaxing validation. Adding an enum value is the awkward case — additive on the wire, breaking for any client whose parser rejects unknown values — so document "treat unknown enum values as unknown" on day one, before anyone has written that parser.

Where the version goes

In the path (/v2/orders) it is visible in logs, routable at the proxy and trivial to explain, but every URL in your system becomes version-specific, which hurts when resources are referenced by URL. In a header (Stripe's Stripe-Version) URLs stay stable and you can pin a version per account, at the cost of being invisible in a browser and easy to omit. Shopify uses date-based versions such as 2026-04, releases quarterly, supports each for at least twelve months, and echoes X-Shopify-API-Version on every response.

Two versions, one codebase

Keep one internal model and a chain of per-version transformers at the edge, each undoing one change. Then announce retirement with Deprecation (RFC 9745, March 2025) and Sunset (RFC 8594), plus a Link with rel="deprecation" pointing at migration notes.

Example

Example · javascript
// versioning.js — one internal model, a chain of downgrades at the edge.
const VERSIONS = ['2025-01-15', '2025-07-01', '2026-01-20'];
const CURRENT = VERSIONS.at(-1);

// Keyed by TARGET version: each function undoes the change made by the
// version that follows it, taking a response one step backwards.
const downgrades = {
  '2025-07-01': (order) => {
    // 2026-01-20 split customer.name into given_name / family_name.
    const { given_name, family_name, ...rest } = order.customer;
    return { ...order, customer: { ...rest, name: `${given_name} ${family_name}` } };
  },
  '2025-01-15': (order) => {
    // 2025-07-01 renamed amount (dollars, float) to amount_cents (integer).
    const { amount_cents, ...rest } = order;
    return { ...rest, amount: amount_cents / 100 };
  },
};

export function downgradeOrder(order, requested) {
  const to = VERSIONS.indexOf(requested);
  if (to === -1) throw new ApiError(400, 'unknown-api-version');
  let out = order;
  for (let i = VERSIONS.indexOf(CURRENT) - 1; i >= to; i--) out = downgrades[VERSIONS[i]](out);
  return out;
}

// Per-request override, else the account's pinned version, else current.
// `??` is the shorthand; the long form is a chain of != null checks.
export function resolveVersion(req) {
  return req.get('Example-Version') ?? req.principal.pinnedVersion ?? CURRENT;
}

// Handlers only ever build the current shape; the edge does the rest.
router.get('/orders/:id', async (req, res) => {
  const order = await orders.get(req.params.id);          // internal model
  const version = resolveVersion(req);
  res.set('Example-Version', version);                    // echo what you applied
  res.json(downgradeOrder(order, version));
});

When to use it

  • Deciding whether renaming a response field needs a new API version
  • Giving external integrators a dated deadline for an endpoint you want to delete
  • Supporting two response shapes without forking the service
  • Working out which accounts still call the version you want to retire

More examples

Announcing the sunset on every response

Headers alone never move anyone; the log line is what lets you email the eleven accounts still on the old version instead of announcing to everybody. Mount this before your routes so it covers errors too — a client stuck on an old version is often a client only seeing failures.

Example · javascript
const RETIRING = {
  '2025-01-15': {
    deprecatedAt: Date.parse('2026-04-01T00:00:00Z'),
    sunsetAt:     Date.parse('2027-04-01T00:00:00Z'),   // 12 months of overlap
  },
};

app.use((req, res, next) => {
  const plan = RETIRING[resolveVersion(req)];
  if (!plan) return next();

  // RFC 9745: a structured-field Date — '@' plus seconds since the epoch.
  res.set('Deprecation', `@${Math.floor(plan.deprecatedAt / 1000)}`);
  // RFC 8594: an HTTP-date. Same pair of headers, different formats.
  res.set('Sunset', new Date(plan.sunsetAt).toUTCString());
  // Where to read about it. res.append adds a field line without clobbering.
  res.append('Link',
    '<https://developer.example.com/api/migrate-2026-01>; rel="deprecation"; type="text/html"');

  req.log.info({ version: resolveVersion(req), accountId: req.principal.accountId },
    'deprecated api version in use');   // this log is how you find who to email
  next();
});

Additive change against breaking change

The last two are the ones that cause incidents, because neither shows up in a schema diff. Keep a changelog entry type for 'new enum value' and 'validation tightened' so they get the same review as a field removal.

Example · http
# Additive: existing clients ignore the new field, new clients use it.
# No version bump needed.
GET /orders/9001
{"id": 9001, "amount_cents": 2500, "currency": "usd",
 "settlement_eta": "2026-10-04"}            <-- added

# Breaking, even though it looks small: a client doing order.customer.name
# throws on undefined.
{"id": 9001, "customer": {"given_name": "Ada", "family_name": "Lovelace"}}

# Breaking in the direction people miss: a new enum value. The wire format
# is unchanged; a client with `switch (status) { ... default: throw }` is not.
{"id": 9001, "status": "partially_refunded"}   <-- was never one of 4 values

# Breaking without touching the schema at all:
POST /orders   {"quantity": 0}
# was 201 Created (clamped to 1), now 422 Unprocessable Content

Path or header, and the pin

Echoing the resolved version is the cheap detail that saves support time: integrators pin a version in a config file, deploy a client that does not send it, and then report that your API 'changed'. The echoed header settles that in one curl.

Example · http
# Path: visible, routable at the proxy, awkward once URLs are stored.
GET /v2/orders/9001

# Header, Stripe-style: URLs stay stable, version pinned per account,
# overridable per request for testing a migration.
GET /orders/9001
Example-Version: 2026-01-20

# Response always states what it actually applied, so a client that forgot
# the header can see which contract it got.
HTTP/1.1 200 OK
Example-Version: 2025-07-01
Deprecation: @1775001600
Sunset: Thu, 01 Apr 2027 00:00:00 GMT
Link: <https://developer.example.com/api/migrate-2026-01>; rel="deprecation"

Discussion

  • Be the first to comment on this lesson.