APIs & Devices NotesRohit’s interview study guide
Chapter 04

Versioning and Compatibility

Changing a device API without breaking a fleet on old firmware: why devices make versioning hard, where the version goes, the rules for compatible change, capability discovery, deprecation and sunset headers, and testing every client against every firmware.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Why devices make versioning hard

Added

On a web service, you deploy the server and within minutes every client is talking to the new version. The only old code left is in the clients, and the System Design course's API versioning topic is mostly about them: mobile apps that stay installed for years, partner scripts nobody maintains. On a device platform the relationship flips. The API server is the device. Every video bar, phone and touch controller runs its own copy of the API, at whatever firmware it last installed, and it may stay on that firmware for years. Your API isn't one version; it's every version you ever shipped that's still plugged in somewhere.

So the problem runs in both directions at once. An old client meets new firmware when a control-system integration written in 2021 talks to a device that updated last night: the device must keep behaving the way the old client expects. A new client meets old firmware when this year's cloud manager or admin dashboard talks to a device that hasn't updated since 2023: the client must notice what the device can't do and degrade politely instead of failing. A web service only has the first problem. A fleet has both, for every pair of client and firmware version that exists in the field.

A good mental model: a fleet is a museum of your releases. Each device is an exhibit, frozen at the release it last installed. Visitors of every age (scripts, integrations, dashboards, the vendor's own cloud) walk through every room, and none of them can repaint an exhibit. Versioning on devices is the job of making every visitor's tour work in every room, or end at a clear sign rather than a crash.

Clients (each built once, at one moment) Devices (each frozen at its firmware) Partner integration written against fw 7.0 in 2021 Cloud manager released this month Admin's browser tab UI loaded before the update Provisioning server pushes one config to all Gen 1 hardware: capped at 7.x 7.0 7.2 7.3 can never install 8.0 Gen 2 hardware 7.2 7.3 8.0 8.0 is one-way: no rollback Old client → new firmware: the device must keep old behaviour. New client → old firmware: the client must detect and degrade.
Every client meets every firmware still in the field. A fictional fleet; the shape (a capped older generation, a one-way major release) matches public Poly facts below.

Why old firmware lingers for so long, even when updates are free and automatic:

  • Hardware that can't run the new line. A new OS base (a newer Android, for example) needs more memory, a newer chipset or vendor drivers that older models don't get.
  • Customers who pin versions. Enterprises run change control: an update is tested in one room, approved, then rolled out on their schedule. Some wait for a partner's certification first.
  • Devices that are rarely online or sit on networks that can't reach the update server, in a cupboard, a closed office, a secure site.
  • One-way upgrades make administrators careful: if they can't roll back, they wait for others to go first.
  • Staged rollouts by the vendor itself: a release reaches 1%, then 10%, then everyone, over weeks.

What public Poly documents show

The role you're preparing for works on exactly this kind of fleet. These are public facts, checked in October 2026:

  • A one-way major release on newer hardware only. HP's official Poly VideoOS Software Release Notes 5.X (HP Support, 5.0.0 released May 2026, read 27 October 2026) say that "only Poly Studio G62, Poly Studio X32, Poly Studio X52, and Poly Studio X72 systems support Poly VideoOS 5.0.0" and that "once you update your system to Poly VideoOS 5.0.0, you cannot downgrade". A post announcing the release in the Poly Video Conferencing Knowledge Base on the HP Support Community (6 May 2026; the site states that posts are their authors' opinions, not HP's) names the Poly Studio X30, X50, X70, G7500 and the TC8 controller as incompatible. Devices must be on 4.6.0 or later first; older ones install 4.6.0 automatically on the way (release notes). The Poly VideoOS 5.0 Compatibility Reference Guide (HP, March 2026) repeats that systems on 5.0 cannot be downgraded to 4.x, and warns not to upgrade if your conferencing provider isn't on its list (5.0 lists Microsoft Teams Rooms and Poly Device Mode).
  • Two release lines at once. A month later, Poly VideoOS 4.7.0 (June 2026) shipped "for all providers", according to its post in the same HP Support Community knowledge base (18 June 2026). The post's title names the newer G62, X32, X52 and X72, and its fixes also mention the G7500 and the Studio X30, X50 and X70. So the 4.x and 5.x lines were both being released in mid-2026, and the older models stay on 4.x.
  • An OS jump underneath. The 5.0.0 release notes describe the platform update on those four models as "from Android 10 to Android 13" (the TC10 touch controller goes from Android 11 to 12). The role description you have talks about Android 11 to 13; the public release notes and the job description differ, so ask which devices they mean.
  • Partner certification gates updates. The 4.7.0 post notes that Microsoft hadn't yet added the release to the Teams Admin Center, so Poly officially didn't recommend updating yet.

The public documents don't say how long the 4.x line will be maintained, or how many devices are on each version. The point stands without those numbers: anyone integrating with these devices' APIs must expect at least two firmware lines, and some hardware that will never leave the older one.

How long the tail is

This seeded model runs a fictional fleet of 10,000 video bars for a year. 40% are Gen 1 hardware that can't run 8.x; most devices update automatically, some customers pin versions, and a few devices are rarely online. Release 7.3 ships in week 0 for everyone; 8.0 ships in week 10 for Gen 2 only.

fleet-tail.mjs
// A seeded model of a device fleet over a year: which firmware versions are still out there?
// 10,000 video bars. Gen 1 hardware can't run 8.x; some customers pin versions; some devices are rarely online.
function mulberry32(a) { // small seeded PRNG, so every run prints the same numbers
  return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const rand = mulberry32(42);
const VERSIONS = ["7.0", "7.1", "7.2", "7.3", "8.0"];
const pick = (weights) => { let r = rand(), i = 0; while (r > weights[i]) r -= weights[i++]; return i; };

const fleet = Array.from({ length: 10_000 }, () => ({
  gen: rand() < 0.4 ? 1 : 2,                                // 40% older hardware
  v: pick([0.1, 0.2, 0.7]),                                 // index into VERSIONS: 7.0, 7.1 or 7.2 today
  policy: ["auto", "auto", "auto", "pinned", "offline"][pick([0.2, 0.2, 0.3, 0.2, 0.1])],
}));
// Chance per week that a device on each policy installs the newest release it is offered.
const weekly = { auto: 0.3, pinned: 0.03, offline: 0.005 };

function newestFor(device, week) {
  if (week >= 10 && device.gen === 2) return 4;             // 8.0 ships in week 10, Gen 2 only
  return 3;                                                 // 7.3 ships in week 0 for everyone
}

function report(week) {
  const counts = VERSIONS.map((_, i) => fleet.filter((d) => d.v === i).length);
  let covered = 0, oldest = VERSIONS.length;               // add versions from the newest down until 99% are covered
  while (covered < 9_900) covered += counts[--oldest];
  const cells = counts.map((n) => `${(n / 100).toFixed(1)}%`.padStart(7)).join("");
  console.log(`week ${String(week).padStart(2)}${cells}   99% needs ${VERSIONS[oldest]}+`);
}

console.log("        " + VERSIONS.map((v) => v.padStart(7)).join("") + "   oldest a client must support");
for (let week = 0; week <= 52; week++) {
  if ([0, 4, 10, 16, 26, 52].includes(week)) report(week);
  for (const d of fleet) {
    const target = newestFor(d, week);
    if (d.v < target && rand() < weekly[d.policy]) d.v = target;
  }
}
const stuck = fleet.filter((d) => d.gen === 1).length;
const on7 = fleet.filter((d) => d.v < 4).length;
console.log(`\nafter a year: ${on7} devices (${on7 / 100}%) still on 7.x, ${stuck} of them Gen 1 hardware that can never run 8.x`);
Result
            7.0    7.1    7.2    7.3    8.0   oldest a client must support
week  0   9.9%  20.1%  70.0%   0.0%   0.0%   99% needs 7.0+
week  4   4.4%   8.9%  31.2%  55.4%   0.0%   99% needs 7.0+
week 10   2.6%   4.9%  18.5%  74.0%   0.0%   99% needs 7.0+
week 16   2.1%   4.1%  15.2%  38.5%  40.2%   99% needs 7.0+
week 26   1.6%   3.4%  12.3%  34.5%  48.1%   99% needs 7.0+
week 52   1.1%   2.3%   8.2%  35.6%  52.8%   99% needs 7.0+

after a year: 4706 devices (47.06%) still on 7.x, 3937 of them Gen 1 hardware that can never run 8.x
What’s happening
  1. Week 0: the fleet starts on three versions (roughly 10% on 7.0, 20% on 7.1, 70% on 7.2). Each device is assigned a hardware generation and a policy from a seeded PRNG (mulberry32 with seed 42), so the run is the same every time.
  2. Each simulated week, every device behind the newest release it's allowed gets a chance to update: 30% for automatic devices, 3% for pinned ones, 0.5% for devices that are rarely online. By week 4, 7.3 already has 55.4% of the fleet: automatic updates work.
  3. In week 10, 8.0 ships for Gen 2 only. By week 16 it has 40.2% and by week 52, 52.8%. It can't go much higher: 3,937 devices (39.37%) are Gen 1 and will run 7.x for the rest of their lives.
  4. The right-hand column is the question a client team actually asks: "what's the oldest firmware we must support to reach 99% of devices?" The code adds up shares from the newest version down until it covers 9,900 devices. After a full year, the answer is still 7.0: 1.1% of the fleet never moved (107 devices; counting them by policy gives 76 rarely online and 31 pinned, and no automatic ones). A year of releases didn't shorten the support window at all.
  5. After the year, 4,706 devices (47.06%) are still on 7.x. A client that only speaks 8.x reaches half the fleet. That's why "just make customers upgrade" isn't an answer, and why the rules in the rest of this chapter matter.
  6. If you change the Gen 1 share from 0.4 to 0 and run it again, 8.0 reaches 86.4% by week 52, and the 99% answer is still 7.0: the pinned and rarely online devices, not the hardware, set the tail. The numbers are invented; the shape, fast early adoption and then a long tail, is the point.
AdvancedEvery client of a device API

Who the clients are

It helps to list every client of a device API, because each one ages differently:

ClientBuilt whenUpdated howVersioning problem
On-device web UIwith the firmwarewith the firmwarealmost none, except a browser tab loaded before an update
Vendor cloud managercontinuouslythe vendor deploys itmust talk to every firmware in the field
Partner integrations (room control systems, schedulers)once, against one firmwarerarely, by someone elsemust keep working on every newer firmware
Customers' scriptsoncealmost neverthe same, with no tests and no owner
Companion devices (touch controllers, cameras)their own firmwaretheir own update cycledevice-to-device compatibility in both directions
Provisioning servers and config filesper customerwhen an admin edits themone config pushed to many firmware versions

The on-device web UI looks like the easy case because it ships with the firmware it talks to. But an administrator's browser tab that loaded the UI before an update is now an old client talking to new firmware after the device reboots. The UI should notice (the firmware version from the API or a response header changes) and reload itself.

AdvancedSupport windows and minimum versions

Because you can't make old firmware disappear, you decide explicitly what you support, and write it down:

  • Clients (the cloud manager, SDKs, the admin dashboard) support a window of firmware, chosen from fleet data, not guessed: "every firmware released in the last three years", or "7.0 and later". The fleet model above is the kind of report that decides it.
  • Firmware supports a window of API versions: each release lists the API major versions it serves (more in Versioning strategies: URL, header and media type).
  • Below the window, fail clearly. A cloud manager meeting firmware older than it supports should say so ("this device runs 6.4; update to 7.0 or later to manage it here"), not show a half-working page.
  • Report versions on the wire. Every device should expose its model, firmware version and supported API versions on a cheap, unauthenticated-or-low-privilege endpoint, so clients and fleet tools can decide before calling anything else.
AdvancedThe on-device UI is a client too
Which side owes compatibility?

A room-control integration written in 2021 against firmware 7.0 recalls camera presets. A cloud dashboard released this month shows a new microphone-zones panel that only firmware 8.0 supports. Both are used across a fleet of 7.0, 7.3 and 8.0 devices. For each one, which direction of compatibility is at stake, and who has to do the work?

Show answer

The integration is an old client meeting new firmware: the firmware owes it. The dashboard is a new client meeting old firmware: the dashboard owes it.

What’s happening
  1. The 2021 integration can't change: nobody will update it. On 7.3 and 8.0 it still sends the 7.0 requests and reads the 7.0 response fields. So every newer firmware must keep those requests working and those fields present with the same meaning. That's backward compatibility, and it's the firmware team's obligation.
  2. The dashboard is new, and it will meet 7.0 and 7.3 devices that have no microphone zones and never will (no firmware update changes the device it's talking to right now). So the dashboard must find out per device whether zones exist and hide or explain the panel. That's the client's obligation, solved with capability discovery.
  3. The firmware team can't help the dashboard on old devices (that firmware is already shipped), and the dashboard team can't help the old integration (it's someone else's code). Each obligation lands on the only side that can still change.
  4. The general rule: whoever ships later carries the burden. New firmware carries old clients; new clients carry old firmware.

#Versioning strategies: URL, header and media type

Added

When a change can't be made compatibly, you need a way for a client to say which shape it expects. There are three places to put that version, and HTTP supports all of them: in the URL path (/api/v2/audio), in a request header (Api-Version: 2), or in the media type the client asks for (Accept: application/vnd.acme.roombar.v2+json). All three let old and new clients use the same device at the same time; they differ in who can see the version, what caches and proxies do with it, and how easy it is to use from a browser, curl or a ten-year-old control system.

A mental model: the version is part of how a letter is addressed. A version in the URL is written in the address itself: every post office (proxy, cache, log file, gateway) can read it and route on it. A header is a note on the corner of the envelope: machines that know to look read it, and a person reading the address doesn't see it. A media type is the line "please reply in French": the same recipient (resource), a different language (representation) for the reply. That's content negotiation, the mechanism HTTP was designed with.

On a device there's a fourth, implicit version that you can't avoid: the firmware version. It decides which API versions exist on that box at all. So the device must say which versions it serves, and a client chooses among those, never assumes.

This model is one Express 5 app that accepts the version all three ways. The device's state exists once; each major version is just a different rendering of it.

version-strategies.mjs
// One Express app that accepts the API version three ways: in the URL, in a header, or in the media type.
import express from "express";
import request from "supertest";

const SUPPORTED = [1, 2];
const audio = { muted: true, muteLocked: false, volume: 62 };   // the device's real state, one copy
const render = {                                                 // one representation per major version
  1: (a) => ({ muted: a.muted, volume: a.volume }),
  2: (a) => ({ mute: { state: a.muted ? "muted" : "unmuted", locked: a.muteLocked }, volume: { level: a.volume, max: 100 } }),
};
const VENDOR = /^application\/vnd\.acme\.roombar\.v(\d+)\+json$/;

function resolveVersion(req, res, next) {
  const fromUrl = req.params.v ? Number(req.params.v) : undefined;
  const accept = (req.get("Accept") || "").split(",").map((s) => s.trim().split(";")[0]).find((t) => VENDOR.test(t));
  const fromMedia = accept ? Number(accept.match(VENDOR)[1]) : undefined;
  const fromHeader = req.get("Api-Version") ? Number(req.get("Api-Version")) : undefined;
  const asked = [fromUrl, fromMedia, fromHeader].filter((v) => v !== undefined);
  if (new Set(asked).size > 1) return problem(res, 400, `conflicting versions: ${asked.join(" vs ")}`);
  const v = asked[0] ?? 1;                                       // nothing asked: assume the oldest client
  if (!SUPPORTED.includes(v)) {
    const status = fromUrl !== undefined ? 404 : fromMedia !== undefined ? 406 : 400;
    return problem(res, status, `version ${v} not supported; supported: ${SUPPORTED.join(", ")}`);
  }
  req.apiVersion = v;
  res.set("Api-Version", String(v));
  if (fromUrl === undefined) res.vary("Accept").vary("Api-Version");   // caches must key on these headers
  res.type(fromMedia ? `application/vnd.acme.roombar.v${v}+json` : "application/json");
  next();
}
const problem = (res, status, detail) => res.status(status).type("application/problem+json").send({ status, detail });

const app = express();
const handler = (req, res) => res.send(JSON.stringify(render[req.apiVersion](audio)));
app.get("/api/v:v/audio", resolveVersion, handler);              // URL versioning
app.get("/api/audio", resolveVersion, handler);                  // header or media-type versioning

const tries = [
  ["/api/v1/audio", {}],
  ["/api/v2/audio", {}],
  ["/api/audio", {}],
  ["/api/audio", { "Api-Version": "2" }],
  ["/api/audio", { Accept: "application/vnd.acme.roombar.v2+json" }],
  ["/api/v1/audio", { "Api-Version": "2" }],
  ["/api/v3/audio", {}],
  ["/api/audio", { Accept: "application/vnd.acme.roombar.v3+json" }],
];
for (const [path, headers] of tries) {
  const res = await request(app).get(path).set(headers);
  const sent = Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("") || "(no version header)";
  console.log(`GET ${path}  ${sent}`);
  console.log(`  ${res.status} ${res.type}${res.headers.vary ? `  Vary: ${res.headers.vary}` : ""}`);
  console.log(`  ${res.text}`);
}
Result
GET /api/v1/audio  (no version header)
  200 application/json
  {"muted":true,"volume":62}
GET /api/v2/audio  (no version header)
  200 application/json
  {"mute":{"state":"muted","locked":false},"volume":{"level":62,"max":100}}
GET /api/audio  (no version header)
  200 application/json  Vary: Accept, Api-Version
  {"muted":true,"volume":62}
GET /api/audio  Api-Version: 2
  200 application/json  Vary: Accept, Api-Version
  {"mute":{"state":"muted","locked":false},"volume":{"level":62,"max":100}}
GET /api/audio  Accept: application/vnd.acme.roombar.v2+json
  200 application/vnd.acme.roombar.v2+json  Vary: Accept, Api-Version
  {"mute":{"state":"muted","locked":false},"volume":{"level":62,"max":100}}
GET /api/v1/audio  Api-Version: 2
  400 application/problem+json
  {"status":400,"detail":"conflicting versions: 1 vs 2"}
GET /api/v3/audio  (no version header)
  404 application/problem+json
  {"status":404,"detail":"version 3 not supported; supported: 1, 2"}
GET /api/audio  Accept: application/vnd.acme.roombar.v3+json
  406 application/problem+json
  {"status":406,"detail":"version 3 not supported; supported: 1, 2"}
What’s happening
  1. audio is the device's real state, one copy. render[1] and render[2] are the two representations: v1 returns flat muted and volume; v2 groups them (mute.state, mute.locked, volume.level, volume.max). Changing muted: true into mute: { state: "muted" } is a breaking change, which is why it needed a new major version rather than an added field. The handler never branches on the version; only render does.
  2. resolveVersion reads all three sources: the :v path parameter, a vendor media type in Accept (matched by the VENDOR regular expression), and the Api-Version header. Requests 1 and 2 use the URL; request 4 the header; request 5 the media type, and its response echoes Content-Type: application/vnd.acme.roombar.v2+json so the client knows which representation it got.
  3. Request 3 sends no version at all and gets v1: const v = asked[0] ?? 1. The default is the oldest version on purpose: a client that sends nothing is, almost by definition, one written before versions existed. Defaulting to the newest would break every one of them on the day v2 ships.
  4. On /api/audio, the same URL returns different bodies depending on headers, so the response says Vary: Accept, Api-Version. Without it, a cache (a proxy, the browser's HTTP cache or a service worker) could store the v2 body and hand it to a v1 client. URL-versioned responses don't need it: the version is in the cache key already.
  5. Request 6 asks for v1 in the path and v2 in a header. The resolver collects the versions asked for into a Set; more than one distinct value means the client is confused, so it answers 400 with a problem-details body instead of guessing. That's 400, not 422, because the conflict is in the request line and headers, not in the body's content.
  6. Unsupported versions fail with the status that fits where they were asked: 404 for /api/v3/audio (that URL doesn't exist on this firmware), 406 Not Acceptable for a media type the server can't produce, 400 for a bad header value. All three list the supported versions in detail, so the error itself tells an integrator what to do.
AdvancedHow the three compare on a device
URL path /api/v2/…Header Api-Version: 2Media type Accept: …v2+json
Visible in logs, links, docsyesnono
Cachingworks by defaultneeds Vary: Api-Versionneeds Vary: Accept
From a browser link, <img>, a log downloadyesno (a plain link can't set headers)no
From curl or a control system's HTTP blocktrivialusually possiblepossible, often awkward
Granularitythe whole API at onceper requestper resource and per representation
Routing on a small on-device serverpath routing, nothing extraone middlewareone middleware plus content negotiation
Seen inmany public REST APIs (/v1/…); Poly's VideoOS REST API has no version segmentGitHub's REST API (X-GitHub-Api-Version)APIs that version single resources (application/vnd.<vendor>.v2+json)

For a device API used by admins, partners and scripts, the usual choice is a major version in the URL (/api/v1, /api/v2), kept for years, and no minor versions on the wire: within a major version every change is additive, and clients find out about new features through capability discovery instead of a version number. Headers or media types make sense when one resource needs a new representation while the rest of the API stays put, or when you follow a convention your partners already use.

AdvancedVersioning in Poly's public REST guide

What Poly's public REST guide shows

The Poly VideoOS REST API Reference Guide (HP, document P55757-001A, last updated August 2025) documents paths under /rest/ with no version segment, for example GET /rest/audio, GET /rest/conferences and GET /rest/system/status, and says its command section describes the commands "for Poly VideoOS version 4.4.0". Separate editions of the guide exist for earlier software releases. In other words, the public documentation is versioned by software release, and the guide doesn't describe a separate API version number or a way to negotiate one. The public docs don't say how Poly handles breaking changes to these paths; if you're asked, say that, then explain the general patterns from this chapter.

AdvancedNegotiating with a fleet

Within a fleet, each firmware serves a set of major versions and each client speaks a set. A client should pick the highest version both know, and when there's none, produce a message a human can act on. This model plays that out for three clients against four firmware releases, including a future 9.0 that finally drops v1:

negotiate.mjs
// Each device lists the major API versions its firmware serves; each client lists what it can speak.
// They agree on the highest version both know, or fail with a message a human can act on.
const devices = {
  "gen1 fw 7.2": { firmware: "7.2", apiVersions: [1] },
  "gen1 fw 7.3": { firmware: "7.3", apiVersions: [1, 2] },
  "gen2 fw 8.0": { firmware: "8.0", apiVersions: [1, 2] },
  "gen2 fw 9.0": { firmware: "9.0", apiVersions: [2, 3] },     // a future release that drops v1
};
const clients = { "partner script": [1], "admin UI 3.0": [1, 2], "cloud agent 6": [2, 3] };

function negotiate(clientVersions, device) {
  const common = clientVersions.filter((v) => device.apiVersions.includes(v));
  if (common.length) return `v${Math.max(...common)}`;
  const tooOld = Math.max(...clientVersions) < Math.min(...device.apiVersions);
  return tooOld ? `none: client too old for fw ${device.firmware}` : `none: needs fw with v${Math.min(...clientVersions)}+`;
}

console.log(("".padEnd(16) + Object.keys(devices).map((d) => d.padEnd(15)).join("")).trimEnd());
for (const [client, versions] of Object.entries(clients)) {
  const cells = Object.values(devices).map((d) => negotiate(versions, d));
  console.log(client.padEnd(16) + cells.map((c) => (c.startsWith("v") ? c : "✗").padEnd(15)).join("").trimEnd());
  for (const [i, c] of cells.entries()) if (!c.startsWith("v")) console.log(`    ✗ on ${Object.keys(devices)[i]}: ${c}`);
}
Result
                gen1 fw 7.2    gen1 fw 7.3    gen2 fw 8.0    gen2 fw 9.0
partner script  v1             v1             v1             ✗
    ✗ on gen2 fw 9.0: none: client too old for fw 9.0
admin UI 3.0    v1             v2             v2             v2
cloud agent 6   ✗              v2             v2             v3
    ✗ on gen1 fw 7.2: none: needs fw with v2+
What’s happening
  1. Each device advertises apiVersions; on a real device that list comes from a small discovery endpoint (for example GET /api returning { "firmware": "8.0", "apiVersions": [1, 2] }) that must itself never change shape, because every client reads it first.
  2. negotiate intersects the two lists and takes the maximum. The admin UI speaks v1 and v2: it uses v1 on the 7.2 device (the only version there) and v2 everywhere else. One client, two wire formats, chosen per device.
  3. The partner script only speaks v1. It works on 7.2, 7.3 and 8.0, and fails on 9.0, the first firmware without v1. The message says "client too old", which points at the client's owner.
  4. The cloud agent speaks v2 and v3, so it can't talk to the 7.2 device at all; the message says it "needs fw with v2+", which points at the device's owner. Telling the two failures apart is the useful part: it decides who gets the support ticket.
  5. This is also why removing v1 from firmware is a much bigger decision than adding v3: every v1-only client in the field stops working on every device that installs 9.0, and you can't see those clients from the firmware side. Deprecation and sunset covers how to get there.
AdvancedCommands, Telnet and SSH have versions too

Command-line and Telnet interfaces (see Designing a command grammar) have no headers and no URLs, so the version has to be negotiated in the session itself: a greeting line that states the firmware and protocol version, a command such as version or capabilities, or a session option that switches to a newer output format (format json). The same rules apply: default to the oldest behaviour, because old scripts send nothing, and never change the format of an existing command's output, because scripts parse it with regular expressions.

The cache that mixed up versions

A device API is header-versioned: GET /api/audio with Api-Version: 2 returns the v2 shape, without the header it returns v1. The responses carry Cache-Control: max-age=5 but no Vary. An admin opens the new admin UI (which sends Api-Version: 2) and, in another tab of the same browser, an old embedded status page (which sends no header). The status page, which displays Math.round(body.volume), sometimes shows "volume: NaN". Why, and what's the fix?

Show answer

The browser's HTTP cache keys the response on the URL only, so for up to five seconds whichever version was fetched first is served to both pages. When the v2 body reaches the old page, body.volume is an object, not a number, and Math.round of an object is NaN. The fix is Vary: Api-Version on every response from that URL (or moving the version into the URL).

What’s happening
  1. max-age=5 makes the response reusable for five seconds. Without Vary, the cache's key for it is just the method and URL, GET /api/audio. The request header that chose the version isn't part of the key.
  2. The admin UI requests first, with Api-Version: 2, and the cache stores the v2 body: { "mute": {…}, "volume": { "level": 62, "max": 100 } }.
  3. Within five seconds the status page requests the same URL without the header. The cache finds a fresh entry for that key and returns the v2 body without asking the device.
  4. The status page reads body.volume expecting 62 and gets { level: 62, max: 100 }; Math.round({ level: 62, max: 100 }) is NaN (checked in Node), so it shows "volume: NaN". It only happens when the two requests land within the cache lifetime and in that order, which is why it looks random.
  5. Vary: Api-Version adds the header's value to the cache key, so v1 and v2 are stored separately. The version-strategies model sets Vary: Accept, Api-Version on the unversioned URL for exactly this reason. URL versioning avoids the whole class of bug, because the version is in the key already.
AdvancedMissing Vary serves the wrong version

#Rules for compatible change

Added

Most changes to a device API should never need a new version. If every change within a major version is compatible, an integration written against the first firmware keeps working on the last one, and a version bump becomes a rare, planned event instead of a monthly one. Compatibility is a property of what clients observe, not what you documented: field names, types, units and meanings, which values can appear, status codes, error codes, defaults, even timings. Anything a client can see, some client depends on.

The rules come from two sides of the contract. The provider may only grow what it returns and what it accepts; it may never remove, rename or repurpose. The consumer reads as a tolerant reader, a pattern Martin Fowler named in his "TolerantReader" article (May 2011): take only the fields you use, ignore the rest, and make as few assumptions about the structure as you can. Each side's discipline is what makes the other side's changes safe. A provider that only adds fields is still breaking a client that rejects unknown fields; a tolerant client is still broken by a provider that changes a type.

A mental model: a device API is a promise made by every firmware you ship, to every client that will ever exist. You can add to a promise ("and now you also get framingZone"). You can't take part of it back, and you can't quietly change what a word in it means, because somewhere there's a client that read the promise once, in 2021, and acts on it every day.

ChangeOld clientsWhy
Add an optional response fieldsafe for tolerant readersstrict schema validators reject it: tell clients to ignore unknown fields from day one
Add a value to a response enumriskyswitch statements, closed enums in generated clients and dropdowns break; document enums as open
Add an optional request fieldsafe, if old firmware rejects unknown fields clearlyold firmware will receive it from new clients: see "Writes" below
Make a request field required, or add a required onebreakingevery old request now fails validation
Remove or rename a fieldbreakingon a device, the old name has to stay for as long as old clients exist
Change a type, unit or meaningbreakingvolume 0–10 becoming 0–100, muted meaning "microphones" becoming "all audio"
Change a defaultbreakinga factory-reset device behaving differently after an update is a change clients see
Widen a request range (volume up to 120)safe for writers, risky for readersreaders now see values they were told couldn't happen
Narrow a request rangebreakingold clients' valid requests start failing
Change status codes, error codes, ordering, timingbreakingclients branch on them; a reboot that now takes 90 s breaks a script that waits 60

This model reads the same endpoint from devices on three firmware releases, plus one bad build, with a strict client and a tolerant one. Both use zod 4 schemas to validate the response.

tolerant-reader.mjs
// Two clients read GET /api/v1/camera from devices on different firmware. One is strict, one is a tolerant reader.
import { z } from "zod";

const responses = {
  "fw 7.2": { tracking: "speaker", presets: 3 },
  "fw 7.3": { tracking: "speaker", presets: 3, framingZone: "wide" },          // added a field
  "fw 8.0": { tracking: "group", presets: 3, framingZone: "wide" },            // added an enum value
  "bad build": { tracking: "speaker", presets: [1, 2, 3] },                     // changed a type: really breaking
};

// Strict: the schema is a closed list of fields and values, as generated from the 7.2 docs.
const Strict = z.strictObject({ tracking: z.enum(["off", "speaker", "frame"]), presets: z.number().int() });

// Tolerant: read only the fields this screen uses, accept any string for an enum, strip everything else.
const Tolerant = z.object({ tracking: z.string(), presets: z.number().int() });
const LABELS = { off: "Off", speaker: "Speaker tracking", frame: "Group framing" };
const label = (t) => LABELS[t] ?? `Other (${t})`;                                // unknown value: show it, don't crash

const why = (err) => err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");

for (const [fw, body] of Object.entries(responses)) {
  console.log(`${fw}  ${JSON.stringify(body)}`);
  const s = Strict.safeParse(body);
  console.log(`  strict:   ${s.success ? "ok" : `FAILS  ${why(s.error)}`}`);
  const t = Tolerant.safeParse(body);
  console.log(`  tolerant: ${t.success ? `ok     shows "${label(t.data.tracking)}", ${t.data.presets} presets` : `FAILS  ${why(t.error)}`}`);
}
Result
fw 7.2  {"tracking":"speaker","presets":3}
  strict:   ok
  tolerant: ok     shows "Speaker tracking", 3 presets
fw 7.3  {"tracking":"speaker","presets":3,"framingZone":"wide"}
  strict:   FAILS  (root): Unrecognized key: "framingZone"
  tolerant: ok     shows "Speaker tracking", 3 presets
fw 8.0  {"tracking":"group","presets":3,"framingZone":"wide"}
  strict:   FAILS  tracking: Invalid option: expected one of "off"|"speaker"|"frame"; (root): Unrecognized key: "framingZone"
  tolerant: ok     shows "Other (group)", 3 presets
bad build  {"tracking":"speaker","presets":[1,2,3]}
  strict:   FAILS  presets: Invalid input: expected number, received array
  tolerant: FAILS  presets: Invalid input: expected number, received array
What’s happening
  1. Firmware 7.2 returns exactly the documented fields. Strict is a z.strictObject with a closed z.enum(["off", "speaker", "frame"]), the kind of schema a generator produces from the 7.2 spec. Both clients pass.
  2. Firmware 7.3 adds framingZone. That's a compatible change by the provider's rules, and the strict client still fails: zod reports Unrecognized key: "framingZone". A strict client turns every additive change into a breaking one, which is why "ignore unknown fields" has to be a written rule for clients, not just a habit of providers.
  3. The tolerant schema is a plain z.object, which in zod 4 strips keys it doesn't declare, and it accepts any string for tracking. On 7.3 it reads the two fields it uses and never sees framingZone.
  4. Firmware 8.0 adds the tracking value "group". The strict client now fails twice (Invalid option: expected one of "off"|"speaker"|"frame" plus the unknown key). The tolerant client passes, and the UI's label function falls back to Other (group) instead of crashing or showing a blank: unknown values are displayed, not hidden.
  5. The bad build changes presets from a number to an array. Both clients fail with expected number, received array, and that's correct: tolerance means ignoring what you don't use and accepting new values; it doesn't mean accepting a field you use in a shape you can't handle. A loud failure here is what tells the firmware team they shipped a breaking change.
  6. The lesson in one line: providers only add, readers only read what they need, enums are open, and types never change.
AdvancedWrites: unknown settings on old firmware

Writes: what old firmware does with a field it has never seen

Responses are only half of it. A new cloud manager will send requests to old firmware too, including settings that firmware has never heard of. What the device does then is a design decision, and the worst answer is the one many servers give by default: silently ignore the unknown field and return 200. The admin sees "Saved", and the setting was never applied.

This model gives firmware 7.2 a settings endpoint that is strict by default and lenient only when the client asks, using the Prefer: handling=lenient preference from RFC 7240 (June 2014):

unknown-settings.mjs
// A new cloud manager sends a setting that firmware 7.2 has never heard of. What should the device do?
import express from "express";
import request from "supertest";

const KNOWN = { volume: (v) => Number.isInteger(v) && v >= 0 && v <= 100, autoAnswer: (v) => typeof v === "boolean" };

function device72() {
  const settings = { volume: 62, autoAnswer: false };
  const app = express().use(express.json());
  app.patch("/api/v1/settings", (req, res) => {
    const unknown = Object.keys(req.body).filter((k) => !(k in KNOWN));
    const invalid = Object.keys(req.body).filter((k) => k in KNOWN && !KNOWN[k](req.body[k]));
    const lenient = /\bhandling=lenient\b/.test(req.get("Prefer") ?? "");      // RFC 7240 preference
    if (invalid.length || (unknown.length && !lenient)) {                        // default: strict, nothing applied
      return res.status(422).type("application/problem+json")
        .send({ status: 422, title: "Settings not applied", unknownSettings: unknown, invalidSettings: invalid });
    }
    for (const k of Object.keys(req.body)) if (k in KNOWN) settings[k] = req.body[k];
    if (lenient) res.set("Preference-Applied", "handling=lenient");
    res.json({ settings, ignored: unknown });                                   // say what was NOT applied
  });
  return app;
}

const app = device72();
const change = { volume: 40, noiseBlock: true };                                // noiseBlock arrived in 7.3
for (const prefer of [undefined, "handling=lenient"]) {
  const req = request(app).patch("/api/v1/settings").send(change);
  const res = await (prefer ? req.set("Prefer", prefer) : req);
  console.log(`PATCH ${JSON.stringify(change)}${prefer ? `  Prefer: ${prefer}` : ""}`);
  console.log(`  ${res.status}${res.headers["preference-applied"] ? `  Preference-Applied: ${res.headers["preference-applied"]}` : ""}`);
  console.log(`  ${res.text}`);
}
const bad = await request(app).patch("/api/v1/settings").set("Prefer", "handling=lenient").send({ volume: 400 });
console.log(`PATCH {"volume":400}  Prefer: handling=lenient\n  ${bad.status}\n  ${bad.text}`);
Result
PATCH {"volume":40,"noiseBlock":true}
  422
  {"status":422,"title":"Settings not applied","unknownSettings":["noiseBlock"],"invalidSettings":[]}
PATCH {"volume":40,"noiseBlock":true}  Prefer: handling=lenient
  200  Preference-Applied: handling=lenient
  {"settings":{"volume":40,"autoAnswer":false},"ignored":["noiseBlock"]}
PATCH {"volume":400}  Prefer: handling=lenient
  422
  {"status":422,"title":"Settings not applied","unknownSettings":[],"invalidSettings":["volume"]}
What’s happening
  1. Firmware 7.2 knows two settings, volume (an integer from 0 to 100) and autoAnswer (a boolean). The new cloud manager sends { volume: 40, noiseBlock: true }, because noise block arrived in 7.3 and the manager pushes one profile to the whole fleet.
  2. With no preference, the device answers 422 (the JSON is well-formed, but the content isn't acceptable to this firmware) and lists unknownSettings: ["noiseBlock"], and applies nothing. Applying volume but not noiseBlock would leave the device half-configured while the request "failed", so the write is all or nothing.
  3. With Prefer: handling=lenient, the client has said in advance that it can cope with partial application. The device applies volume, skips noiseBlock, answers 200 with Preference-Applied: handling=lenient (so the client knows the preference was honoured) and reports ignored: ["noiseBlock"]. The cloud manager can now show "applied, except noise block: not supported on firmware 7.2" for that one device.
  4. Leniency covers unknown fields only. { volume: 400 } with the same preference still fails, invalidSettings: ["volume"]: an out-of-range value for a setting the device does know is an error in any mode.
  5. RFC 7240 describes handling=strict and handling=lenient as preferences the server applies at its discretion, about whether to reject a request with recoverable errors or to process it. Making strict the default for configuration writes is a choice: for settings, a silent partial success is worse than an error.
AdvancedNever repurpose, and on devices, rarely remove

Renaming a field on a web API is done with expand and contract: add the new field next to the old one, migrate clients, watch usage of the old one fall to zero, remove it. On a device API the contract step usually never comes. Clients you can't see keep reading the old field, and firmware already in the field will return it forever anyway. So:

  • Add the new name, keep the old one, and fill both from the same value, for the life of that major version.
  • Mark the old one deprecated in the spec and the docs (OpenAPI 3.1 has deprecated on operations and parameters, and schemas can use JSON Schema's deprecated annotation), and in responses where it fits (Deprecation and sunset).
  • Never give an old name a new meaning. If volume was 0–10 and you need 0–100, add volumePercent; don't redefine volume. A client that reads the wrong scale doesn't fail, it just sets the room to 7% instead of 70%.
  • Remove only in a new major version, and only after the old major has been deprecated for long enough that your fleet data says almost nobody uses it.

This model is that rename on a single device: firmware 7.3 replaces volume (0–10, since the first release) with volumePercent (0–100), and keeps serving both from one stored value.

rename-field.mjs
// Firmware 7.3 replaces "volume" (0-10, from the very first release) with "volumePercent" (0-100).
// It can't remove or redefine "volume": 2021 clients read and write it. So it serves both, from one value.
const state = { percent: 70 };

const read = () => ({ volume: Math.round(state.percent / 10), volumePercent: state.percent });  // fill both

function write(body) {
  const hasOld = "volume" in body, hasNew = "volumePercent" in body;
  if (hasOld && hasNew && body.volume !== Math.round(body.volumePercent / 10)) {
    return { status: 422, error: "volume and volumePercent disagree; send one" };
  }
  if (hasNew) state.percent = body.volumePercent;          // the precise field wins when both are sent
  else if (hasOld) state.percent = body.volume * 10;
  return { status: 200, body: read() };
}

const steps = [
  ["old client reads", () => read()],
  ["old client writes", () => write({ volume: 4 })],
  ["new client writes", () => write({ volumePercent: 45 })],
  ["old client reads", () => read()],
  ["new client sends both", () => write({ volume: 5, volumePercent: 45 })],
  ["confused client", () => write({ volume: 9, volumePercent: 45 })],
  ["old UI saves unchanged", () => write({ volume: read().volume })],   // read-modify-write of the whole form
];
for (const [who, run] of steps) console.log(who.padEnd(22), JSON.stringify(run()));
Result
old client reads       {"volume":7,"volumePercent":70}
old client writes      {"status":200,"body":{"volume":4,"volumePercent":40}}
new client writes      {"status":200,"body":{"volume":5,"volumePercent":45}}
old client reads       {"volume":5,"volumePercent":45}
new client sends both  {"status":200,"body":{"volume":5,"volumePercent":45}}
confused client        {"status":422,"error":"volume and volumePercent disagree; send one"}
old UI saves unchanged {"status":200,"body":{"volume":5,"volumePercent":50}}
What’s happening
  1. The device stores one value, state.percent. read() derives both fields from it every time, so the two can never drift apart: 70 is returned as volume: 7 and volumePercent: 70.
  2. An old client writes { volume: 4 } and the device stores 40. A new client writes { volumePercent: 45 } and the device stores 45; the old client then reads volume: 5, the nearest step on its scale. Both kinds of client keep working on the same device at the same time.
  3. A new client that sends both fields consistently (5 and 45) gets the precise field: volumePercent wins. A client that sends contradictory values (9 and 45) gets 422 (well-formed JSON whose content contradicts itself) and a message saying which fields disagree, instead of the device picking one silently.
  4. The last line is the trap. An old admin page that reads the whole form and saves it back unchanged sends { volume: 5 }, and the device stores 50. Nobody touched the volume, and it moved from 45 to 50. Mapping a precise field onto a coarse one is lossy, and read-modify-write clients round-trip the loss.
  5. Two fixes, both worth knowing: have the device ignore an old-field write whose value equals what it would currently return for that field (here volume: 5 while read().volume is 5), or tell clients to send only the fields they changed, which partial updates with PATCH make natural. Either way, a test for "save without changes changes nothing" belongs in the compatibility suite.
AdvancedSecurity fixes are the hard exception

Sometimes a breaking change is the right call, because the old behaviour is unsafe. A public example: the Poly VideoOS 4.7.0 release post in the Poly Video Conferencing Knowledge Base on the HP Support Community (18 June 2026; a community post, not an official HP document) says that beginning with 4.6.2, Poly added enhanced REST API security protections, an X-XSRF token requirement for REST API calls, and that 4.7.0 added a setting to turn the requirement off (Security > Access, "Require X-XSRF Authentication Token When Using REST API"), on by default. HP Poly recommends leaving it enabled; the post says disabling it is for systems that can't send X-XSRF tokens. The public notes don't say how many integrations were affected.

The shape is worth copying for any security-driven break: secure by default, an explicit compatibility switch for clients that can't change, a clear recommendation, and documentation that names the change. Add one thing the public notes don't mention and you'd want in your own design: log when the switch is used, so you know who still depends on it.

Safe, risky or breaking?

Firmware 8.1 is planned with five changes to the /api/v1 settings and status endpoints: (a) a new response field lastRebootReason; (b) camera.tracking may now return "presenter"; (c) autoAnswer will default to true after a factory reset instead of false; (d) the maximum of volume rises from 100 to 120; (e) GET /api/v1/status will omit sip when SIP isn't configured instead of returning "sip": null. Classify each for old clients.

Show answer

(a) safe for tolerant readers; (b) risky; (c) breaking; (d) safe for writers, risky for readers; (e) breaking.

What’s happening
  1. (a) A new response field is the textbook compatible change: tolerant readers never see it. Only clients that validate with closed schemas fail, and the docs should already tell them not to.
  2. (b) A new enum value is additive on paper, but any client with an exhaustive switch, a closed generated enum or a fixed dropdown breaks. It's safe only if the API documented from the start that new values may appear, and even then you announce it in the release notes.
  3. (c) A default is behaviour clients observe. Rooms reset to factory settings will start answering calls automatically, which a client never asked for; that's a breaking (and here also privacy-relevant) change, and it belongs in a new major version or behind an explicit setting.
  4. (d) Old writers only ever send 0 to 100, which is still valid, so writes are safe. But old readers may now see 110 and draw a volume bar past its end or reject the value; for them it's risky, and a capabilities document that reports the range (max: 120) is the fix.
  5. (e) Removing a key that used to be present with null breaks clients that read status.sip.state after checking status.sip === null: they now see undefined and take the wrong branch, or throw. "Absent" and "null" are different shapes. Keep returning null.
AdvancedA new enum value in a dropdown
Advanced"Additive" that still breaks

#Capability discovery

Added

A firmware version number is a poor proxy for what a device can do. What a UI actually needs to know is "can this device do X?", and on a device platform the answer depends on much more than the release: the hardware model (a camera that can't do group framing), the mode it runs in (when a partner app such as Teams owns the call, the device's own dialler is off), licences, connected peripherals (no second camera plugged in), and administrator policy (a feature disabled for the whole site). Two devices on the same firmware can differ in all of those. So instead of guessing from the version, the client asks the device, and the device answers with a list of capabilities.

The mental model is USB enumeration. When you plug in a device, the computer doesn't look up its manufacturing date to decide what it can do; it reads the device's descriptors (I'm a keyboard, I have these endpoints, I draw this much power) and configures itself from them. A capabilities endpoint is the API's descriptor: the admin UI, the cloud manager and partner integrations read it first and build themselves from it.

Poly's public REST guide has a small example of the idea. The Poly VideoOS REST API Reference Guide (P55757-001A, August 2025) documents GET /rest/conferences/capabilities, which returns the system's conference call capabilities as booleans (canBlastDial, canMakeAudioCall, canMakeVideoCall), and each conference returned by GET /rest/conferences carries its own capabilities object (canHangupConference, canPresent, canMuteConference and others), which is capability in the current state, not just in general. Its error table also lists 405 Not Allowed, "Requested action is not supported", and 501 Not Implemented. The public guide doesn't describe a single document of features for the whole device; the model below shows what one looks like.

Admin UI or cloud manager GET /api/v1/capabilities first call to every device 200: device's list features + limits 404: older firmware GET /api/v1/system frozen legacy table old firmware never changes render from features never from version numbers
Ask first; fall back to a frozen table only for firmware that predates the question.

This model has four simulated devices: two hardware generations, firmware 7.2 (from before the capabilities endpoint existed), 7.3 and 8.0, and one device in a partner mode. The admin UI discovers capabilities and renders from them; for comparison it also shows what a UI that guesses from the version number would have shown.

capabilities.mjs
// The admin UI asks each device what it can do, instead of guessing from the firmware version.
import express from "express";
import request from "supertest";

function makeDevice({ model, firmware, mode, features, limits }) {
  const app = express();
  app.get("/api/v1/system", (req, res) => res.json({ model, firmware, mode }));
  if (features) app.get("/api/v1/capabilities", (req, res) => res.json({ features, limits })); // 7.3 and later
  return app;
}
const tracking = (...values) => ({ version: values.includes("group") ? 2 : 1, values });
const devices = {
  "A gen1 fw 7.2":         makeDevice({ model: "gen1", firmware: "7.2", mode: "native" }),   // no capabilities endpoint
  "B gen1 fw 7.3":         makeDevice({ model: "gen1", firmware: "7.3", mode: "native",
    features: { "calls.dial": {}, "camera.tracking": tracking("off", "speaker") }, limits: { "cameras.max": 1 } }),
  "C gen2 fw 7.3 partner": makeDevice({ model: "gen2", firmware: "7.3", mode: "partner",       // a partner app owns calls
    features: { "camera.tracking": tracking("off", "speaker", "frame"), "audio.noiseBlock": {} }, limits: { "cameras.max": 3 } }),
  "D gen2 fw 8.0":         makeDevice({ model: "gen2", firmware: "8.0", mode: "native",
    features: { "calls.dial": {}, "camera.tracking": tracking("off", "speaker", "frame", "group"), "audio.noiseBlock": {},
      "audio.zones": { version: 1 } }, limits: { "cameras.max": 3 } }),
};

// What firmware before the capabilities endpoint is known to support: frozen, because that firmware never changes.
const LEGACY = { "7.2": { features: { "calls.dial": {}, "camera.tracking": tracking("off", "speaker") }, limits: { "cameras.max": 1 } } };

async function discover(app) {
  const caps = await request(app).get("/api/v1/capabilities");
  if (caps.status === 200) return { source: "device", ...caps.body };
  const { body: sys } = await request(app).get("/api/v1/system");
  return { source: `legacy table for ${sys.firmware}`, ...(LEGACY[sys.firmware] ?? { features: {}, limits: {} }) };
}

function renderUi({ features, limits }) {               // the UI is built from features, never from version numbers
  const ui = [];
  if (features["calls.dial"]) ui.push("dial pad");
  const t = features["camera.tracking"];
  if (t) ui.push(`tracking [${t.values.join("|")}]`);
  if (features["audio.noiseBlock"]) ui.push("noise block");
  if (features["audio.zones"]) ui.push("mic zones");
  ui.push(`add camera (max ${limits["cameras.max"] ?? 1})`);
  return ui.join(", ");
}

// The tempting alternative: guess from the version number ("noise block shipped in 7.3").
function guessFromVersion({ firmware }) {
  const v = Number(firmware);
  const values = ["off", "speaker", ...(v >= 7.3 ? ["frame"] : []), ...(v >= 8 ? ["group"] : [])];
  return { features: { "calls.dial": {}, "camera.tracking": { values }, ...(v >= 7.3 ? { "audio.noiseBlock": {} } : {}),
    ...(v >= 8 ? { "audio.zones": {} } : {}) }, limits: { "cameras.max": v >= 7.3 ? 3 : 1 } };
}

for (const [name, app] of Object.entries(devices)) {
  const caps = await discover(app);
  const asked = renderUi(caps);
  const guessed = renderUi(guessFromVersion((await request(app).get("/api/v1/system")).body));
  console.log(`${name}  (capabilities from ${caps.source})`);
  console.log(`  asked:   ${asked}`);
  console.log(`  guessed: ${guessed === asked ? "same" : guessed + "   <- WRONG"}`);
}
Result
A gen1 fw 7.2  (capabilities from legacy table for 7.2)
  asked:   dial pad, tracking [off|speaker], add camera (max 1)
  guessed: same
B gen1 fw 7.3  (capabilities from device)
  asked:   dial pad, tracking [off|speaker], add camera (max 1)
  guessed: dial pad, tracking [off|speaker|frame], noise block, add camera (max 3)   <- WRONG
C gen2 fw 7.3 partner  (capabilities from device)
  asked:   tracking [off|speaker|frame], noise block, add camera (max 3)
  guessed: dial pad, tracking [off|speaker|frame], noise block, add camera (max 3)   <- WRONG
D gen2 fw 8.0  (capabilities from device)
  asked:   dial pad, tracking [off|speaker|frame|group], noise block, mic zones, add camera (max 3)
  guessed: same
What’s happening
  1. makeDevice builds an Express app per device. Every one serves GET /api/v1/system (model, firmware, mode); only 7.3 and later also serve GET /api/v1/capabilities with a features map and limits. Device A, on 7.2, answers that path with Express's default 404.
  2. discover asks for capabilities first. For B, C and D it gets 200 and uses the device's own answer (from device). For A it gets 404, falls back to /api/v1/system, reads firmware: "7.2" and looks it up in LEGACY, a table the client ships with. That table is safe to hard-code for one reason: released firmware never changes, so what 7.2 supports is a fixed historical fact.
  3. renderUi builds the screen from features only: a dial pad if calls.dial exists, the tracking choices from camera.tracking.values, noise block and microphone zones if present, and the camera limit from limits. There is no if (firmware >= …) anywhere in it.
  4. Device B is the case version checks get wrong: Gen 1 hardware on 7.3. The guess assumes "7.3 has noise block, group framing and three cameras", but that hardware has none of them. Asked, the device reports tracking [off|speaker] and max 1, so the UI offers only what will work.
  5. Device C is on Gen 2 hardware with the same 7.3 firmware, but in partner mode, where a partner app owns calling. It reports no calls.dial, so no dial pad. The version guess shows one, which would fail the moment someone pressed it. Same firmware as B, different hardware and mode, different screen.
  6. Devices A and D come out the same both ways, which is exactly why version checks survive code review: they're right on the devices developers test with. One more trap hides in guessFromVersion: Number(firmware) makes "7.10" the number 7.1, which compares as older than "7.3". Version strings need a real comparison (split on dots and compare numbers), and capability discovery avoids the comparison altogether.
AdvancedDesigning the capability document

A capability document is an API like any other, and every client depends on it, so its shape must be the most stable thing you have. A sketch:

JSON
{
  "features": {
    "calls.dial": { "version": 1 },
    "camera.tracking": { "version": 2, "values": ["off", "speaker", "frame", "group"] },
    "audio.noiseBlock": { "version": 1, "state": "disabled-by-policy" },
    "audio.zones": { "version": 1 }
  },
  "limits": { "cameras.max": 3, "volume.max": 100, "presets.max": 10 }
}
  • Stable, namespaced names (camera.tracking, not feature17). Once published, a name is never reused for something else.
  • Absent means not supported. Clients treat unknown feature names as something to ignore (tolerant reading again) and missing names as "no".
  • A version per feature when a feature itself evolves: camera.tracking version 2 added group. A client that only knows version 1 still knows how to use it.
  • Allowed values and limits belong here, not in client code. This solves the enum problem from Rules for compatible change: the dropdown's options come from values, so a new value appears in the UI the day the firmware ships it.
  • Say why something is off when the device knows: "state": "disabled-by-policy" lets the UI say "Disabled by your administrator" instead of hiding the switch, which sends people looking for a feature they were told exists.
  • Static and dynamic are different questions. "This model can share content" belongs in the document; "content sharing is possible right now" depends on call state and belongs on the call resource (as Poly's per-conference capabilities object does) or in an event.
  • Make it cheap and cacheable: an ETag so clients revalidate with If-None-Match, and an event when it changes, because plugging in a camera or switching modes changes the answer while the client is connected.
AdvancedThe other direction: clients declare what they understand

Discovery tells a client what the device can do. The reverse also helps: if the client says which features it understands, the device can avoid sending things the client can't handle. This matters most for pushed data such as events on a WebSocket, where the client can't negotiate per request. This model has firmware 8.0 send mute events, one with the new state muted-by-host, plus a microphone-zone event that older clients have never heard of:

declared-features.mjs
// The other direction: clients declare what they understand, and the device only sends them that.
// Firmware 8.0 can report that the meeting host muted the room ("muted-by-host"); older clients only know muted/unmuted.
const DOWNGRADES = {
  "mute.hostState": (e) => (e.type === "mute" && e.state === "muted-by-host" ? { ...e, state: "muted" } : e),
  "audio.zones": (e) => (e.type === "zone" ? null : e),         // whole event type is new: drop it for old clients
};

function subscribe(declared) {                                  // e.g. the first frame on a WebSocket: { features: [...] }
  const missing = Object.keys(DOWNGRADES).filter((f) => !declared.includes(f));
  return (event) => missing.reduce((e, f) => (e ? DOWNGRADES[f](e) : e), event);
}

const events = [
  { seq: 41, type: "mute", state: "muted-by-host" },
  { seq: 42, type: "zone", zone: 2, active: true },
  { seq: 43, type: "mute", state: "unmuted" },
];
const clients = { "admin UI 2.x": [], "admin UI 3.0": ["mute.hostState"], "cloud agent 6": ["mute.hostState", "audio.zones"] };

for (const [name, features] of Object.entries(clients)) {
  const send = subscribe(features);
  const out = events.map(send).filter(Boolean).map((e) => `${e.seq}:${e.state ?? `zone ${e.zone}`}`);
  console.log(`${name.padEnd(14)} declares [${features.join(", ")}]`);
  console.log(`${"".padEnd(14)} receives ${out.join("  ")}`);
}
Result
admin UI 2.x   declares []
               receives 41:muted  43:unmuted
admin UI 3.0   declares [mute.hostState]
               receives 41:muted-by-host  43:unmuted
cloud agent 6  declares [mute.hostState, audio.zones]
               receives 41:muted-by-host  42:zone 2  43:unmuted
What’s happening
  1. DOWNGRADES maps each feature to what the device does for a client that didn't declare it: map muted-by-host to the nearest old value muted, or drop a whole new event type (zone) by returning null.
  2. subscribe(declared) works out which features the client lacks and builds a send function that applies their downgrades in turn. On a real device this would run once when the client's first WebSocket message, say { "features": ["mute.hostState"] }, arrives.
  3. admin UI 2.x declared nothing, so it receives 41:muted and 43:unmuted: correct for its vocabulary, and no surprise value to crash on. It never sees event 42.
  4. admin UI 3.0 declared mute.hostState, so it gets the precise muted-by-host, but still no zone event. cloud agent 6 declared both and gets all three events unchanged.
  5. Look at what the old clients receive: sequence numbers 41, 43. A client that checks for gaps in sequence numbers (see Snapshot plus events) would think it lost event 42 and resync. Filtering per client means either numbering events per subscription or sending a small placeholder for each filtered one. Every compatibility trick has a cost somewhere else; this one is cheap if you see it coming.
AdvancedProbing: try it and see

The oldest form of discovery is to just call the endpoint and handle the failure: 404 (no such resource on this firmware), 405 (the method isn't supported here) or 501 (the server doesn't support the functionality). It's fine as a fallback for firmware that predates a capabilities endpoint, with three rules: probe only with safe requests (GET, HEAD, OPTIONS; never a POST that might actually do something), cache the answer per device and firmware instead of probing on every page load, and remember that a small device's request budget is shared with everything else talking to it (see Designing for constrained devices).

A feature version you've never seen

Your admin UI knows camera.tracking versions 1 and 2. A device on new firmware reports "camera.tracking": { "version": 3, "values": ["off", "speaker", "frame", "group", "presenter"] }. What should the UI do, and what rule does the capability document need for that answer to be safe?

Show answer

Use the feature as the newest version it knows (2) and build the dropdown from values, including the unfamiliar presenter. That's safe only if the document's rule is that feature versions are additive: version 3 does everything version 2 did, plus more. An incompatible redesign gets a new feature name instead.

What’s happening
  1. The UI can't refuse the feature: hiding camera tracking on the newest devices because they're "too new" would be the worst possible outcome of a compatibility scheme.
  2. With an additive rule, version 3 means "everything in version 2, plus something". So every request the UI knows how to make for version 2 still works, and the UI can safely treat the feature as version 2.
  3. The new value presenter arrives in values, so the dropdown shows it even though this UI has never heard of it. The label falls back to the raw value or a generic "Presenter" built from it, the same tolerant-reader rule as for response enums.
  4. If the firmware team ever needs to change tracking incompatibly (different request shape, different meaning), they publish it under a new name such as camera.trackingZones and keep reporting camera.tracking for as long as it works. Old UIs keep using the old feature; new UIs prefer the new one.
  5. Write that rule into the capability document's own specification. Without it, every client has to guess what a higher feature version means, and some will guess "refuse".
AdvancedSupported, available and allowed are three different answers
AdvancedCapability lists that only grow

#Deprecation and sunset

Added

Eventually something has to go: a v1 endpoint replaced by v2, a setting that no longer makes sense, a protocol that isn't safe any more. Removing it is a breaking change, so it happens in two announced steps. Deprecation says "stop building on this; there's a replacement", while the thing keeps working exactly as before. Sunset says "this is expected to stop working at a certain time". HTTP has a standard header for each, so a client can learn about both from the responses it already gets, not only from documentation nobody rereads.

The two standards, read in October 2026:

  • RFC 9745, "The Deprecation HTTP Response Header Field" (Standards Track, March 2025). The value is a Structured Field Date (RFC 9651): an @ followed by Unix seconds, for example Deprecation: @1688169599 for 30 June 2023 23:59:59 UTC. The date can be in the past (deprecated since then) or the future (will be deprecated then). Section 5 is explicit that deprecation changes no behaviour of the resource. A Link with rel="deprecation" points to documentation about the deprecation (a policy or migration guide for the client's developers; the RFC allows it to be human- or machine-readable). Section 4 adds the rule that ties the two headers together: the Sunset timestamp must not be earlier than the Deprecation one.
  • RFC 8594, "The Sunset HTTP Header Field" (Informational, May 2019). The value is an HTTP-date (Sunset: Sat, 31 Dec 2018 23:59:59 GMT), which should be in the future; a past date is safest read as "now". Clients should treat it as a hint: the resource isn't guaranteed to work until then, or to stop working after. After the sunset, requests are likely to get 4xx, 3xx or no answer at all. A Link with rel="sunset" points to the retirement policy, upcoming sunsets or migration information.
  • Scope: both headers apply to the resource whose response carries them. Both RFCs allow an API to document a wider scope (for example, announcing on the API's home resource that the whole API is deprecated), with the warning that clients who don't know that convention will read it as applying to that one resource.

A mental model: a shop that's closing down. The "no longer stocking this line" sign (Deprecation) goes up first; you can still buy one today, at the same price. The "closing on 31 December" sign (Sunset) gives the date. Both signs are on the door of the specific shop, and the head office's leaflet (the Link) explains where to go instead. Nobody is forced to read the signs, so the shop doesn't rely on them alone.

2026 2027 2028 fw 7.3 adds v2 fw 8.0 v1 deprecated Sunset date 31 Dec 2027 fw 9.0 first without v1 A device that installs 9.0 serves v1 with Deprecation + Sunset headers v1 gone A device that stays on 8.x serves v1 for as long as it runs 8.x, sunset date or not
On a device, the sunset date is when releases may start removing something; removal happens when each device installs one.

This model is a device's API marking its v1 audio endpoint deprecated as of 30 June 2026, with a sunset of 31 December 2027, and a client that reads the headers.

deprecation.mjs
// A device marks its v1 audio endpoint deprecated (RFC 9745) with a sunset date (RFC 8594); a client notices.
import express from "express";
import request from "supertest";

function deprecate({ deprecatedAt, sunsetAt, docs, policy }) {
  if (sunsetAt < deprecatedAt) throw new Error("RFC 9745: Sunset must not be earlier than Deprecation");
  const usage = new Map();                                    // who still calls it: the list you'll need later
  const mw = (req, res, next) => {
    res.set("Deprecation", `@${Math.floor(deprecatedAt / 1000)}`);   // Structured Field Date: @ + Unix seconds
    res.set("Sunset", new Date(sunsetAt).toUTCString());              // HTTP-date, for historical reasons
    res.append("Link", `<${docs}>; rel="deprecation"; type="text/html"`);
    res.append("Link", `<${policy}>; rel="sunset"`);               // the retirement policy (RFC 8594 section 6)
    const client = req.get("User-Agent") ?? "unknown";
    usage.set(client, (usage.get(client) ?? 0) + 1);
    next();                                                   // deprecation changes nothing about behaviour
  };
  return Object.assign(mw, { usage });
}

const v1Deprecation = deprecate({
  deprecatedAt: Date.UTC(2026, 5, 30, 23, 59, 59),            // 30 June 2026
  sunsetAt: Date.UTC(2027, 11, 31, 23, 59, 59),               // 31 December 2027
  docs: "https://example.com/roombar/audio-v2",
  policy: "https://example.com/roombar/lifecycle",
});
const app = express();
app.get("/api/v1/audio", v1Deprecation, (req, res) => res.json({ muted: true, volume: 62 }));
app.get("/api/v2/audio", (req, res) => res.json({ mute: { state: "muted" }, volume: { level: 62 } }));

// Client side: read the hints, never depend on them.
const NOW = Date.UTC(2026, 9, 27);                            // a fixed "today" (27 October 2026) so output is stable
function checkDeprecation(path, res) {
  const dep = res.headers.deprecation?.match(/^@(-?\d+)$/);
  if (!dep) return `${path}: not deprecated`;
  const since = new Date(Number(dep[1]) * 1000).toISOString().slice(0, 10);
  const sunset = res.headers.sunset ? Date.parse(res.headers.sunset) : undefined;
  const days = sunset ? Math.ceil((sunset - NOW) / 86_400_000) : undefined;
  const docs = res.headers.link?.match(/<([^>]+)>;\s*rel="deprecation"/)?.[1];
  return `${path}: DEPRECATED since ${since}` + (days !== undefined ? `, sunset in ${days} days` : "") + ` (${docs})`;
}

const calls = [["/api/v1/audio", "room-scheduler/2.1"], ["/api/v1/audio", "room-scheduler/2.1"],
  ["/api/v1/audio", "acme-cloud/5.4"], ["/api/v2/audio", "acme-cloud/5.4"]];
for (const [i, [path, ua]] of calls.entries()) {
  const res = await request(app).get(path).set("User-Agent", ua);
  if (i === 0) for (const h of ["deprecation", "sunset", "link"]) console.log(`${h}: ${res.headers[h]}`);
  console.log(`${ua.padEnd(19)} ${res.status} ${checkDeprecation(path, res)}`);
}
console.log("still calling v1:", Object.fromEntries(v1Deprecation.usage));
try {
  deprecate({ deprecatedAt: Date.UTC(2027, 0, 1), sunsetAt: Date.UTC(2026, 0, 1), docs: "x", policy: "y" });
} catch (e) { console.log("misconfigured:", e.message); }
Result
deprecation: @1782863999
sunset: Fri, 31 Dec 2027 23:59:59 GMT
link: <https://example.com/roombar/audio-v2>; rel="deprecation"; type="text/html", <https://example.com/roombar/lifecycle>; rel="sunset"
room-scheduler/2.1  200 /api/v1/audio: DEPRECATED since 2026-06-30, sunset in 431 days (https://example.com/roombar/audio-v2)
room-scheduler/2.1  200 /api/v1/audio: DEPRECATED since 2026-06-30, sunset in 431 days (https://example.com/roombar/audio-v2)
acme-cloud/5.4      200 /api/v1/audio: DEPRECATED since 2026-06-30, sunset in 431 days (https://example.com/roombar/audio-v2)
acme-cloud/5.4      200 /api/v2/audio: not deprecated
still calling v1: { 'room-scheduler/2.1': 2, 'acme-cloud/5.4': 1 }
misconfigured: RFC 9745: Sunset must not be earlier than Deprecation
What’s happening
  1. deprecate() builds Express middleware for one deprecated resource. It refuses to start if the sunset is earlier than the deprecation (the RFC 9745 section 4 rule), so a misconfigured release fails in testing instead of in the field; the last output line shows that check firing.
  2. On each response it sets Deprecation: @1782863999: an @ and the Unix seconds of 30 June 2026 23:59:59 UTC. It sets Sunset: Fri, 31 Dec 2027 23:59:59 GMT with toUTCString(), which produces the HTTP-date format RFC 8594 requires. The two formats differ because the headers were standardised years apart; RFC 9745 notes that the Sunset header uses a different date format for historical reasons.
  3. Two Link values are appended: the migration guide with rel="deprecation" and the retirement policy with rel="sunset". On the wire Express sends two separate Link: lines (checked with a raw socket); Node's HTTP client joins them with a comma, which is why the printed link is one line. HTTP treats the two forms as the same, so a client's parser must handle both.
  4. The handler still answers 200 with the same body. Deprecation is information, not a behaviour change, so every old client keeps working; only clients that look at headers notice.
  5. The client's checkDeprecation parses the @ number back into a date, computes days until the sunset from a fixed "today" (27 October 2026, so the output never changes): 431 days. The v2 endpoint has no Deprecation header, so it reports "not deprecated". A real client would log a warning once per endpoint, not per call.
  6. The middleware also counts calls per User-Agent. That count is the most useful output of the whole exercise: room-scheduler/2.1 made two v1 calls and acme-cloud/5.4 one. That's the list of integrations to contact before anything is removed. On a device this count lives on each box, so it only helps if something collects it (more below).
AdvancedWhat Sunset means for a device API

On a hosted API, the sunset date is when the provider switches the old version off for everyone. On a device API nobody can switch anything off: firmware that already shipped keeps serving v1 for as long as it runs. Removal happens when each device installs a release without v1, and that is spread over months or years (see the fleet model in Why devices make versioning hard). So for a device the honest meaning of Sunset is "firmware released after this date may no longer include this", and the linked policy should say which release line removes it. Three consequences:

  • The headers are baked into firmware. Once 8.0 ships with a sunset of 31 December 2027, that date is on those devices forever. If plans change, newer firmware can send a later date, but 8.0 devices keep sending the old one. Pick dates you can keep; the RFCs call them hints, which helps when you can't.
  • Usage data is spread across the fleet. The per-client counter in the model above is per device. To answer "who still calls v1?" you need devices to report deprecated-feature use to the management service (with the client identity they saw), or a cloud manager that records which API version it uses per device.
  • No brownouts. Hosted APIs switch the old version off for an hour to flush out forgotten integrations. You can't do that on a customer's device. The device equivalent is a setting: in a later release, turn the deprecated feature off by default for new installs and factory resets, but leave it on for devices that upgrade with it in use, and give admins a switch. That's the same shape as the X-XSRF compatibility setting in Rules for compatible change, in reverse.
AdvancedTelling people, not just clients

Headers reach code; people need other channels. A deprecation that only exists in a header will be discovered when something breaks.

  • The API reference: mark the operation, parameter or field deprecated in the OpenAPI document (operations and parameters have a deprecated field; schemas can use JSON Schema's deprecated annotation), with the replacement and the planned removal release.
  • Release notes with a standing "Deprecated" and "Removed" section, so integrators can scan every release for the one thing that matters to them.
  • The admin UI: a banner such as "2 integrations used the deprecated v1 audio API in the last 7 days", with the client names. Admins can act on that; a header they never see, they can't.
  • SDKs: mark the deprecated method (@deprecated in JSDoc, which TypeScript editors show struck through), so developers see it while typing.
  • Partners directly: the usage list is a contact list.
  • Non-HTTP interfaces: a Telnet or SSH command can print a warning line before its normal output, but only if the command grammar has a warning format scripts already ignore; a provisioning parameter that's going away should be accepted, logged and reported as deprecated, not rejected.

An illustrative policy, the kind of text that goes behind the rel="sunset" link: "A deprecated API keeps working in every release of the firmware line it was deprecated in. It may be removed in the next major firmware line, no earlier than 18 months after deprecation. Deprecations are announced in release notes, the API reference and Deprecation headers." The numbers are an example, not a recommendation for any real product.

Read the headers

A device's response includes Deprecation: @1798761599 and Sunset: Wed, 31 Dec 2025 23:59:59 GMT. Today is 27 October 2026. What do these headers say, and what should the client's developer do?

Show answer

They contradict each other: the resource will be deprecated on 31 December 2026, but its sunset was a year earlier, which RFC 9745 doesn't allow. The developer should ask the device vendor which date is right, and meanwhile treat the resource as possibly unavailable at any time.

What’s happening
  1. @1798761599 is a Structured Field Date in Unix seconds: new Date(1798761599 * 1000).toISOString() gives 2026-12-31T23:59:59.000Z. So the resource is not deprecated yet; it will be at the end of 2026.
  2. The Sunset header is an HTTP-date: 31 December 2025, ten months in the past.
  3. RFC 9745 section 4 says the Sunset timestamp must not be earlier than the Deprecation timestamp, and if it is (by misconfiguration or error), the client's developer should consult the resource's developer. That's the first action: report it, with the exact headers.
  4. Until then, RFC 8594's advice applies to the past sunset date: it's safest to read it as "now", meaning the resource could become unavailable at any time. The integration should start moving to the replacement named by the rel="deprecation" link, if there is one.
  5. On a device, the likely cause is a release that copied the wrong constant into its headers, which also means every device on that firmware sends the same contradiction until it updates. Reporting it to the vendor fixes future releases, not the installed ones.
AdvancedDeprecated is not "about to break"

#Testing compatibility across versions

Added

Every rule in this chapter can be broken by accident, by a developer who didn't know a partner parses that field, or by a refactor that changed a default. You can't reason your way to compatibility across several firmware releases and several clients; you test the grid: every supported client against every supported firmware. Three tools make that practical. A version matrix runs each client's real code against each firmware, using simulators in CI and real devices in the lab. Recorded fixtures capture what each released firmware actually returned, once, and replay it forever. Contract diffs compare one release's API with the next before it ships, so breaking changes are caught in review rather than in a customer's meeting room.

Recorded fixtures have a property that's special to devices: released firmware never changes, so a recording of what 7.2 returns is a permanent fact, not a snapshot that goes stale. The test suite for 2029's cloud manager can still prove it works with 2026's firmware, without a 2026 device on the bench.

A mental model: the crash-test matrix. A car maker doesn't test "the car"; it tests each model against each barrier, at each speed, and publishes the grid. A compatibility matrix is that grid for software: rows are clients, columns are firmware, every cell is a test, and a red cell blocks the release that caused it.

This model builds a simulator of three firmware releases from one factory (each if is a real difference between releases), runs three clients' read and write scenarios against all of them, and prints the grid.

compat-matrix.mjs
// Run every supported client against a simulator of every supported firmware, and print the matrix.
import express from "express";
import request from "supertest";

function firmware(version) {                     // one simulator, shaped by what each release really changed
  const v = { "7.2": 0, "7.3": 1, "8.0": 2 }[version];
  const state = { muted: false, volume: 62 };
  const app = express().use(express.json());
  app.use((req, res, next) => {                  // 8.0 hardening: every write needs a CSRF token
    if (v >= 2 && req.method !== "GET" && !req.get("X-CSRF-Token")) return res.status(403).json({ error: "csrf token required" });
    next();
  });
  app.get("/api/v1/audio", (req, res) => res.json({ ...state, ...(v >= 1 && { muteLocked: false }) }));
  app.patch("/api/v1/audio", (req, res) => { Object.assign(state, req.body); res.json(state); });
  if (v >= 1) {
    app.get("/api/v1/capabilities", (req, res) => res.json({ features: { "audio.v2": {} } }));
    app.get("/api/v2/audio", (req, res) =>
      res.json({ mute: { state: v >= 2 ? "muted-by-host" : "unmuted" }, volume: { level: state.volume } }));
  }
  return app;
}

const clients = {
  "partner script (2021)": {                     // written against 7.2, never updated
    read: async (app) => (await request(app).get("/api/v1/audio")).body.volume,
    write: async (app) => (await request(app).patch("/api/v1/audio").send({ volume: 40 })).status,
  },
  "admin UI 3.0": {                              // discovers features, sends the token, tolerates new values
    read: async (app) => {
      const caps = await request(app).get("/api/v1/capabilities");
      if (caps.status !== 200 || !caps.body.features["audio.v2"]) return (await request(app).get("/api/v1/audio")).body.volume;
      return (await request(app).get("/api/v2/audio")).body.volume.level;
    },
    write: async (app) => (await request(app).patch("/api/v1/audio").set("X-CSRF-Token", "t0k").send({ volume: 40 })).status,
  },
  "cloud agent (strict)": {                      // generated from the 7.3 spec with closed enums, always uses v2
    read: async (app) => {
      const res = await request(app).get("/api/v2/audio");
      if (res.status !== 200) throw new Error(`v2 → ${res.status}`);
      if (!["muted", "unmuted"].includes(res.body.mute.state)) throw new Error(`enum "${res.body.mute.state}"`);
      return res.body.volume.level;
    },
    write: async (app) => (await request(app).patch("/api/v1/audio").set("X-CSRF-Token", "t0k").send({ volume: 40 })).status,
  },
};

const FW = ["7.2", "7.3", "8.0"];
console.log(("client / scenario".padEnd(36) + FW.map((f) => `fw ${f}`.padEnd(22)).join("")).trimEnd());
let failures = 0;
for (const [name, c] of Object.entries(clients)) {
  for (const scenario of ["read", "write"]) {
    const cells = [];
    for (const f of FW) {
      try {
        const out = await c[scenario](firmware(f));
        const ok = scenario === "read" ? out === 62 : out === 200;
        cells.push(ok ? "pass" : `FAIL (${out})`);
      } catch (e) { cells.push(`FAIL (${e.message})`); }
    }
    failures += cells.filter((x) => x !== "pass").length;
    console.log((`${name} / ${scenario}`.padEnd(36) + cells.map((x) => x.padEnd(22)).join("")).trimEnd());
  }
}
console.log(`\n${failures} failing cells of ${Object.keys(clients).length * 2 * FW.length}`);
Result
client / scenario                   fw 7.2                fw 7.3                fw 8.0
partner script (2021) / read        pass                  pass                  pass
partner script (2021) / write       pass                  pass                  FAIL (403)
admin UI 3.0 / read                 pass                  pass                  pass
admin UI 3.0 / write                pass                  pass                  pass
cloud agent (strict) / read         FAIL (v2 → 404)       pass                  FAIL (enum "muted-by-host")
cloud agent (strict) / write        pass                  pass                  pass

3 failing cells of 18
What’s happening
  1. firmware(version) turns "7.2", "7.3" or "8.0" into an Express app. 7.3 adds a field (muteLocked), the capabilities endpoint and /api/v2/audio. 8.0 adds a new mute.state value (muted-by-host) and a security change: writes need an X-CSRF-Token header. Building all three from one function keeps the differences visible in a dozen lines, which is how simulators stay reviewable.
  2. Each client is its real request logic, not a description of it. The partner script from 2021 reads /api/v1/audio and writes without a token. Admin UI 3.0 discovers capabilities and sends the token. The strict cloud agent was generated from the 7.3 spec with closed enums and always uses v2.
  3. The matrix runs 3 clients × 2 scenarios × 3 firmware = 18 cells, each against a fresh simulator. A read passes if the client gets volume 62; a write passes on 200.
  4. Partner script / write on 8.0: FAIL (403). The CSRF hardening broke the one client that can't change. The matrix doesn't decide whether that's acceptable, but it makes sure someone decides before release: add a compatibility setting, or document the break.
  5. Cloud agent / read on 7.2: FAIL (v2 → 404) because 7.2 has no v2: the agent skipped discovery and assumed. On 8.0: FAIL (enum "muted-by-host") because of its closed enum. Admin UI 3.0 passes every cell, because it does the three things this chapter recommends: discover, fall back, tolerate.
  6. Three red cells out of 18, each pointing at a specific rule. In CI this exits non-zero and names the cell. The partner-script row is the most valuable one in the grid: it's the only place that client's behaviour is tested at all, because its owner isn't going to.
AdvancedRecorded fixtures and contract diffs

A simulator is only as good as its fidelity to real firmware, so the second tool works from what real devices returned. In the lab, a recorder calls each endpoint on a device running each released firmware, in several states (in a call, idle, muted, with peripherals and without), and saves the responses as fixture files. The model below compares 7.3's recordings with 8.0's by inferring a shape from the samples, every path, every JSON type, every string value seen, and diffing the shapes:

fixture-diff.mjs
// Diff what two firmware releases ACTUALLY returned (recorded from lab devices), not what their specs claim.
const recorded = {                     // e.g. fixtures/fw-7.3/*.json and fixtures/fw-8.0/*.json, recorded in the lab
  "7.3": [
    { mute: { state: "muted" }, volume: { level: 62 }, mics: [{ id: 1, kind: "table" }] },
    { mute: { state: "unmuted" }, volume: { level: 40 }, mics: [{ id: 1, kind: "ceiling" }] },
  ],
  "8.0": [
    { mute: { state: "muted-by-host" }, volume: { level: 62, max: 100 }, mics: [{ id: "1", kind: "table" }] },
    { mute: { state: "unmuted" }, volume: { level: 40, max: 100 }, mics: [] },
  ],
};

// Walk every sample and record, per path, the JSON types seen and (for strings) the values seen.
function shapeOf(samples) {
  const shape = new Map();
  const walk = (value, path) => {
    const type = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
    const entry = shape.get(path) ?? shape.set(path, { types: new Set(), values: new Set() }).get(path);
    entry.types.add(type);
    if (type === "string") entry.values.add(value);
    if (type === "array") value.forEach((item) => walk(item, `${path}[]`));
    if (type === "object") for (const [k, v] of Object.entries(value)) walk(v, path ? `${path}.${k}` : k);
  };
  samples.forEach((s) => walk(s, ""));
  return shape;
}

function diff(oldShape, newShape) {
  const out = [];
  for (const [path, was] of oldShape) {
    const now = newShape.get(path);
    if (!now) out.push(["BREAKING", `${path} no longer returned`]);
    else if ([...now.types].some((t) => !was.types.has(t))) out.push(["BREAKING", `${path} type ${[...was.types]} → ${[...now.types]}`]);
    else if (was.values.size) {
      const added = [...now.values].filter((v) => !was.values.has(v));
      if (added.length) out.push(["RISKY", `${path} new value(s) ${added.map((v) => `"${v}"`).join(", ")}`]);
    }
  }
  for (const path of newShape.keys()) if (path && !oldShape.has(path)) out.push(["added", `${path}`]);
  return out;
}

for (const [kind, text] of diff(shapeOf(recorded["7.3"]), shapeOf(recorded["8.0"]))) console.log(kind.padEnd(9), text);
Result
RISKY     mute.state new value(s) "muted-by-host"
BREAKING  mics[].id type number → string
added     volume.max
What’s happening
  1. recorded stands in for two folders of fixture files, two samples per release for one endpoint. Each sample was captured in a different device state, which matters: a value only shows up in the recordings if the device was in a state that produces it.
  2. shapeOf walks every sample recursively and records, for each path such as mute.state, volume.level or mics[].id, the set of JSON types seen and, for strings, the set of values. Arrays contribute their items under path[], so mics[].kind collects table and ceiling.
  3. diff checks each old path against the new shape. mute.state was a string with values muted and unmuted; 8.0 also produced muted-by-host, so it's RISKY: a new enum value, exactly the change that broke the strict cloud agent in the matrix.
  4. mics[].id was a number in 7.3 and is a string ("1") in 8.0: BREAKING. Nobody wrote that change down; a serializer changed. A diff of the specs would have missed it if the spec still said integer, and this is the real reason to diff recordings: they show what the firmware does, not what the docs claim.
  5. volume.max appears for the first time: added, safe for tolerant readers. The model reports, it doesn't judge: the release review decides whether the risky and breaking lines are acceptable.
  6. The limits are worth saying out loud: a path that's absent from every recording can't be compared, and an empty array (mics: [] in one 8.0 sample) tells you nothing about its items. Fixture coverage is a test plan of its own: record every state a client can see.

To compare the specs of two releases instead, run a breaking-change check over the two OpenAPI documents in CI, the same classification as the System Design course's breaking-changes model, and the Documenting APIs with OpenAPI chapter's contract testing topic covers turning the spec into tests. Recordings and specs check each other: a recording that doesn't validate against its own release's spec is a bug in one of them.

AdvancedChoosing the matrix

A full grid grows fast: clients × firmware × hardware models × modes. Ways to keep it honest without making it endless:

  • Bound it by the support window from Why devices make versioning hard: every firmware a client claims to support is a column, and nothing older.
  • Weight by fleet share. A firmware on 0.3% of devices still gets tested if it's supported, but nightly on recordings, not on every pull request on hardware.
  • Pairwise for configurations. Testing every combination of model, mode and peripherals is rarely possible. Covering every pair of values (pairwise or combinatorial testing) needs far fewer runs and, as a rule of thumb, catches many of the bugs that come from two settings interacting.
  • Run each layer where it's cheap. Pull requests: simulators and recorded fixtures, in seconds. Nightly: the matrix against lab devices on every supported release. Release candidates: the full hardware-in-the-loop suite (the testing and delivery chapter covers device simulators and lab testing).
  • Keep the old clients' tests forever. The partner script row is cheap to keep and impossible to recreate once its author is gone. When a partner reports an integration, record its requests and add it as a row.

A CI job for the simulator layer can run the same suite once per firmware with a matrix. This is a sketch for a repository where npm run test:compat starts the simulator for FIRMWARE (it was checked with actionlint, not run):

.github/workflows/compat.yml
name: compatibility
on:
  pull_request:
  schedule:
    - cron: "0 3 * * *" # nightly, when the lab devices are free

jobs:
  matrix:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false # show every red cell, not just the first
      matrix:
        firmware: ["7.2", "7.3", "8.0"]
        client: [partner-script, admin-ui, cloud-agent]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 26
      - run: npm ci
      - run: npm run test:compat -- --client "${{ matrix.client }}"
        env:
          FIRMWARE: ${{ matrix.firmware }}

fail-fast: false matters here: GitHub Actions cancels the rest of a matrix when one job fails unless you turn that off, and a compatibility grid is only useful when you see all of it.

AdvancedSimulators drift from real firmware
AdvancedThe test that only runs the newest firmware

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.