APIs & Devices NotesRohit’s interview study guide
Chapter 02

REST API Design for Devices

How to shape an HTTP API that admins, the on-device web UI, cloud management and partners all drive: resources for settings, status and operations, safe methods and retries, JSON Merge Patch, long-running reboots and updates, ETags so two admins don't overwrite each other, RFC 9457 errors, atomic bulk configuration, and what a small device can afford — with one simulated device API, built in Express 5, that grows through the chapter.

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

#Modelling a device as resources

Added

A device API is the device's control panel turned into URLs. Everything a person could do standing in front of a meeting-room video bar with its admin web page open (read the firmware version, see whether the camera is connected, change the default volume, place a test call, reboot it) becomes a request a program can send. The callers are more varied than for a typical web back end: the device's own web UI, a cloud management service looking after thousands of devices, a room-booking panel outside the door, a partner's room-control system, and an IT admin's script. They were written by different teams, in different years, against different firmware versions, and they all need the same API to make sense.

The mental model that keeps the design clean is three drawers: what you chose, what it reports, and what you asked it to do. Settings (configuration) are what an admin decides: default volume, hostname, which meeting provider to use; they change rarely, are saved to flash and survive reboots and exports. Status (reported state) is what the device tells you: call state, temperature, which peripherals are plugged in; it changes on its own, often every second, and nobody "sets" it. Operations are things you ask it to do that take time and may fail: reboot, update firmware, run a network test. Around those three sit two simpler kinds of resource: the device's identity (model, serial, firmware: read-only) and collections of things that come and go (peripherals, calls, log bundles).

This is the same split cloud IoT platforms make. AWS IoT's Device Shadow documents, for example, keep a desired section that apps write and a reported section that the device writes, and compute a delta between them (AWS IoT Developer Guide, "Device Shadow service documents", checked October 2026). Keeping the drawers separate isn't tidiness for its own sake: they have different writers, different rates of change, different permissions and different persistence, and almost every later topic in this chapter (ETags, caching, PATCH, bulk apply) works better when they're apart.

/api/v1 /device identity read-only /settings what you chose admins write /status what it reports device writes collections things that come and go /operations what you asked reboot, update /settings/audio /settings/network GET, PATCH GET only; changes every second /peripherals/{id} /calls/{id} /logs /operations/{id} created by POST → 202 Peripherals appear when the device detects them (clients can't POST a camera). Calls are created by clients: POST /calls dials, DELETE /calls/{id} hangs up.
One device, five kinds of resource. The colours are the three drawers: settings (chosen), status (reported), operations (asked for).

Here is the smallest version of that tree as a running server. Every later topic adds to this simulated video bar, a made-up model called SIM-100:

device-resources.mjs
import express from "express";
import { once } from "node:events";

// One simulated meeting-room video bar. What an admin chooses (settings) and what the
// device reports (status) are separate resources; peripherals and calls are collections.
const device = { serial: "SIM100-0042", model: "SIM-100", firmware: "4.2.1" };
const settings = { audio: { defaultVolume: 50, muteOnJoin: false }, display: { brightness: 80 } };
const status = { callState: "idle", temperatureC: 41 };
const peripherals = new Map([
  ["cam-1", { id: "cam-1", kind: "camera", connected: true }],
  ["mic-1", { id: "mic-1", kind: "microphone", connected: false }],
]);
const calls = new Map();
let nextCall = 1;

const v1 = express.Router();
v1.get("/device", (req, res) => res.json(device));
v1.get("/settings", (req, res) => res.json(settings));
v1.get("/status", (req, res) => res.json(status));
v1.get("/peripherals", (req, res) => res.json({ items: [...peripherals.values()] }));
v1.get("/peripherals/:id", (req, res) => {
  const p = peripherals.get(req.params.id);
  if (!p) return res.status(404).json({ title: "Not Found" });
  res.json(p);
});
v1.post("/calls", (req, res) => {
  const call = { id: `call-${nextCall++}`, dialString: req.body.dialString, state: "connecting" };
  calls.set(call.id, call);
  status.callState = "in-call";
  res.status(201).location(`/api/v1/calls/${call.id}`).json(call);
});
v1.delete("/calls/:id", (req, res) => {
  if (!calls.delete(req.params.id)) return res.status(404).json({ title: "Not Found" });
  if (calls.size === 0) status.callState = "idle";
  res.status(204).end();
});

const app = express();
app.use(express.json());
app.use("/api/v1", v1);
// Bind to the address the client calls. A bare listen(0) binds "::", and on macOS another app's
// 127.0.0.1 listener on the same port then wins, so fetch would reach the wrong server.
const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}/api/v1`;

async function call(method, path, body) {
  const res = await fetch(base + path, {
    method,
    headers: body ? { "content-type": "application/json" } : {},
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  const where = res.headers.get("location") ? `  Location: ${res.headers.get("location")}` : "";
  console.log(`${method.padEnd(6)} ${path.padEnd(22)} ${res.status} ${text}${where}`);
}

await call("GET", "/device");
await call("GET", "/peripherals/mic-1");
await call("GET", "/peripherals/hdmi-9");
await call("POST", "/calls", { dialString: "sip:standup@example.com" });
await call("GET", "/status");
await call("DELETE", "/calls/call-1");
await call("DELETE", "/calls/call-1");
await call("GET", "/status");
server.close();
Result
GET    /device                200 {"serial":"SIM100-0042","model":"SIM-100","firmware":"4.2.1"}
GET    /peripherals/mic-1     200 {"id":"mic-1","kind":"microphone","connected":false}
GET    /peripherals/hdmi-9    404 {"title":"Not Found"}
POST   /calls                 201 {"id":"call-1","dialString":"sip:standup@example.com","state":"connecting"}  Location: /api/v1/calls/call-1
GET    /status                200 {"callState":"in-call","temperatureC":41}
DELETE /calls/call-1          204 
DELETE /calls/call-1          404 {"title":"Not Found"}
GET    /status                200 {"callState":"idle","temperatureC":41}
What’s happening
  1. Four plain objects hold the device: device (identity), settings (what an admin chose), status (what the device reports) and two Maps for the collections. They're separate on purpose: status.callState changes when a call starts, and that must not look like anyone edited the settings.
  2. GET /device and GET /peripherals/mic-1 are simple reads. The microphone shows "connected": false: that's reported state about a peripheral, and nobody can PATCH it to true. Asking for hdmi-9, which doesn't exist, gives 404, the same as any missing member of a collection.
  3. POST /calls with a dial string creates a call. It returns 201 Created, the new call as the body and Location: /api/v1/calls/call-1, so the client knows the call's URL without building it. Placing a call is modelled as creating a resource, not as a verb like /dial.
  4. The next GET /status shows "callState": "in-call". The client never wrote that: the device changed its own reported state as a side effect of the call starting. That's the status drawer doing its job.
  5. DELETE /calls/call-1 hangs up and returns 204 No Content. The second DELETE returns 404 because the call is already gone. The state on the device is the same after one hang-up or two ("no call"), which is what makes DELETE idempotent; a client that retried a hang-up should treat that 404 as "already done", not as a failure.
  6. The last GET /status is back to "idle". Six URLs and three methods describe the whole interaction, and every caller (web UI, cloud, partner) uses the same ones.
AdvancedSingletons, collections and sub-resources

Devices have more singletons than most APIs. There is exactly one /settings, one /status and one /device per device, so they have no id in the path, and they support GET and PATCH (sometimes PUT), never POST or DELETE: you can't create a second set of settings or delete the device's status. Write them as singular nouns.

Collections hold things with their own lifecycle. Who creates the members matters:

CollectionWho creates membersMethods clients useNotes
/peripheralsthe device, when it detects hardwareGETids must be stable across reboots: use the serial number or port, not "the second USB device found"
/callsclients (dialling) and the device (incoming calls)GET, POST, DELETEDELETE hangs up; call state lives on the call resource
/logsthe deviceGET (list), GET /logs/{id} (download)bundles are large: stream them, page the list
/operationsthe server, in response to POST on an actionGET, cancelsee Long-running operations
/directory/entriesclientsfull CRUDan ordinary business collection

Sub-resources split a big document into sections a client can read and write separately: /settings/audio, /settings/network, /settings/provider. They make payloads smaller, let you give each section its own ETag (so changing audio doesn't conflict with someone editing the network), and map naturally onto permissions (a room technician can change audio, only a network admin can change network). Keep the whole-document /settings as well, because export, import and bulk apply want everything at once.

Naming rules that save partners pain later: JSON members in lowerCamelCase; units in the name (sleepAfterMinutes, temperatureC, bitrateKbps), because a number without a unit is a bug report waiting to happen; enums as lowercase strings ("idle", "in-call"), never integers whose meaning lives in a PDF; booleans phrased positively (muteOnJoin, not dontUnmuteOnJoin); timestamps as RFC 3339 strings in UTC.

AdvancedWhat a published device API looks like

It helps to compare with a real one. HP publishes the Poly VideoOS REST API Reference Guide (part number P55757-001A, "Last update: August 2025", describing Poly VideoOS 4.4.0), which covers the Poly Studio G62, G7500, Studio X72, X70, X52, X50, X32 and X30. A few of its documented commands, quoted as the guide lists them:

Guide's commandWhat it does (per the guide)What it shows about the design
POST /rest/session with { "user", "password" }creates and authenticates a session; the response includes a sessionId and the rolelogin is a resource you create; every other command needs the session
GET /rest/system/statusstatus of important areas of the system (network, SIP server, gatekeeper…)reported state in its own resource
GET / POST /rest/audio/volumeread the level / set it; the body is a bare integerread and write on the same noun
POST /rest/conferences, DELETE /rest/conferences/{confID}start a conference / end itcalls as a collection
POST /rest/system/reboot with { "action": "reboot" }reboots the systeman action as POST to a noun, with the verb in the body
GET /rest/system/logsthe logs as a .tar file, an octet-stream attachmentlarge downloads are their own resource

The guide's prerequisites also say administrator credentials are required, that request and response bodies are JSON unless stated otherwise, and that "the REST API cannot accept more than 10 MB of data". Its error table lists 200, 201, 204, 400, 403, 404, 405, 415, 500, 501 and 507. The guide doesn't document PATCH, ETags, problem-details error bodies or long-running operation resources, so don't assume Poly devices have them; this chapter teaches those as general patterns. The guide also doesn't say, in the command tables, how the sessionId is sent with later requests. How this VideoOS API relates to the PolyOS 4.x/5.x platform the role mentions isn't stated in the public docs checked for this page (October 2026).

AdvancedVerbs in paths and one giant resource
Is microphone mute a setting or status?

A partner asks for an endpoint to mute the room's microphones during a call, and the web UI wants a "mute microphones when a call starts" checkbox. Where does each one live?

Show answer

They're two different things: the checkbox is a setting (/settings/audio → muteOnJoin: true) and the live mute is state of the current call or audio path (for example PUT /api/v1/audio/mute with true, or a muted member on /calls/{id}).

What’s happening
  1. Ask the three-drawer question for each: did an admin choose it, does the device report it, or is it something you do? "Mute when a call starts" is a choice an admin makes once and expects to survive reboots and appear in an exported configuration: settings.
  2. Live mute changes many times a minute, from the table microphone's button, the touch panel, the far end or the partner's control system. It must not be saved to flash or exported, and it isn't the admin's configuration: it's runtime state you can also write.
  3. Writable runtime state is fine as long as it's its own resource. PUT /api/v1/audio/mute with true is idempotent ("the microphones are muted"), so a retried request can't toggle it back, unlike a POST /audio/toggle-mute.
  4. If both were one field in /settings, pressing the mute button would change the settings' ETag and revision, show up in the audit log as a configuration change, and be wiped by a configuration import. That's the bug the split prevents. (Poly's VideoOS guide, for comparison, has GET/POST /rest/audio/muted with a bare boolean body, separate from configuration.)

#Methods, status codes and idempotency for device APIs

Added

HTTP methods carry two promises defined in RFC 9110 (HTTP Semantics, June 2022): safe methods (GET, HEAD, OPTIONS and TRACE) ask for no change, and idempotent methods (the safe ones plus PUT and DELETE) have "the same intended effect on the server" however many times an identical request is sent. RFC 9110 spells out why that matters: idempotent requests "can be repeated automatically if a communication failure occurs before the client is able to read the server's response", and a client "SHOULD NOT automatically retry a request with a non-idempotent method" unless it has some other way to know that's safe. The generic version of this, with a model of which calls survive a retry, is in the System Design course's Designing an API in the interview; this topic is about what changes when the server is a device.

What changes is that lost responses are normal, not rare. Devices sit on meeting-room Wi-Fi and hotel-grade networks, behind proxies and VPNs, and they reboot themselves in the middle of conversations: after a firmware update, a configuration change, a watchdog reset. A management service talking to ten thousand devices will see thousands of timeouts a day where the request was applied and only the reply was lost. So the mental model is: every request will be sent twice one day; design so the second copy is harmless. For most of the API that's free if you pick the right method. For the dangerous handful (reboot, factory reset, start an update, place a call) you have to add it.

The method choice is really a choice of retry behaviour, and the status code is a promise to software you'll never see: a partner's integration will branch on 409 versus 503 for years. Getting both right is most of what "a well-behaved device API" means.

retry-after-lost-response.mjs
import express from "express";
import { once } from "node:events";

// The device applies every request, but the first response of each scenario is lost
// (the socket closes before the reply). The client retries once, as many SDKs and scripts do.
let dropNextResponse = false;
const lose = (req, res, next) => {
  res.reply = (fn) => (dropNextResponse ? ((dropNextResponse = false), req.socket.destroy()) : fn());
  next();
};

const device = { rebootsScheduled: 0, volume: 50, nextOp: 1 };
const seenKeys = new Map(); // Idempotency-Key -> the response we sent the first time

const app = express();
app.use(express.json(), lose);
app.post("/api/v1/system/reboot", (req, res) => {
  const key = req.get("idempotency-key");
  if (key && seenKeys.has(key)) return res.reply(() => res.status(202).json({ ...seenKeys.get(key), replayed: true }));
  device.rebootsScheduled++;
  const body = { operation: `op-${device.nextOp++}` };
  if (key) seenKeys.set(key, body);
  res.reply(() => res.status(202).json(body));
});
app.put("/api/v1/audio/volume", (req, res) => {
  device.volume = req.body.level; // "the volume is now X": same result however often it's sent
  res.reply(() => res.json({ level: device.volume }));
});
app.post("/api/v1/audio/volume-up", (req, res) => {
  device.volume += 10; // "add 10": every copy of the request adds 10 again
  res.reply(() => res.json({ level: device.volume }));
});

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}/api/v1`;

async function sendWithRetry(label, method, path, { body, headers = {} } = {}) {
  dropNextResponse = true;
  const log = [];
  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      const res = await fetch(base + path, {
        method,
        headers: { "content-type": "application/json", ...headers },
        body: JSON.stringify(body ?? {}),
      });
      log.push(`attempt ${attempt}: ${res.status} ${await res.text()}`);
      break;
    } catch (err) {
      log.push(`attempt ${attempt}: ${err.cause?.code ?? err.message}`);
    }
  }
  console.log(`${label}\n  ${log.join("\n  ")}`);
}

await sendWithRetry("POST reboot, no key", "POST", "/system/reboot");
console.log(`  -> reboots scheduled: ${device.rebootsScheduled}`);
device.rebootsScheduled = 0;
await sendWithRetry("POST reboot, Idempotency-Key: 7c1e", "POST", "/system/reboot", { headers: { "idempotency-key": "7c1e" } });
console.log(`  -> reboots scheduled: ${device.rebootsScheduled}`);
await sendWithRetry("PUT volume { level: 40 }", "PUT", "/audio/volume", { body: { level: 40 } });
console.log(`  -> volume: ${device.volume}`);
await sendWithRetry("POST volume-up", "POST", "/audio/volume-up");
console.log(`  -> volume: ${device.volume} (one press was intended: 50)`);
server.close();
Result
POST reboot, no key
  attempt 1: UND_ERR_SOCKET
  attempt 2: 202 {"operation":"op-2"}
  -> reboots scheduled: 2
POST reboot, Idempotency-Key: 7c1e
  attempt 1: UND_ERR_SOCKET
  attempt 2: 202 {"operation":"op-3","replayed":true}
  -> reboots scheduled: 1
PUT volume { level: 40 }
  attempt 1: UND_ERR_SOCKET
  attempt 2: 200 {"level":40}
  -> volume: 40
POST volume-up
  attempt 1: UND_ERR_SOCKET
  attempt 2: 200 {"level":60}
  -> volume: 60 (one press was intended: 50)
What’s happening
  1. res.reply is a small middleware trick: when dropNextResponse is set, the handler's work still happens, but instead of sending the response it destroys the socket. That's exactly what a client sees when Wi-Fi drops or the device's proxy times out after the action ran. Each scenario sets the flag, so the first attempt always loses its reply.
  2. Reboot without a key: attempt 1 schedules reboot op-1 and the socket dies, so fetch rejects with UND_ERR_SOCKET (Node's fetch is undici, and that's its "socket closed" code). The client can't tell whether anything happened, retries, and attempt 2 schedules op-2. Two reboots: the room drops out of service twice, and if the second reboot hits the device mid-boot it can interrupt a firmware step. rebootsScheduled is 2.
  3. Reboot with Idempotency-Key: 7c1e: attempt 1 schedules op-3 and stores { operation: "op-3" } under the key before the socket dies. Attempt 2 carries the same key, finds it in seenKeys, and replays the stored answer with replayed: true instead of rebooting again. One reboot, and the client still learns the operation id it needs to poll.
  4. PUT /audio/volume { level: 40 } needs no key: it says what the volume is, so applying it twice leaves 40. That's the method doing the work. POST /audio/volume-up says "add 10"; the lost reply plus the retry adds 20, so the volume goes 40 → 60 when the user pressed once and expected 50.
  5. The lesson for a device API: model state as PUT ("the volume is 40", "the microphones are muted") wherever you can, keep relative actions (volume-up, next-camera-preset) for interactive remotes where a lost press is visible and harmless, and put an idempotency key or a client-chosen operation id on every POST that does something you can't undo.
AdvancedWhich method for which device action
ActionMethod and pathIdempotent?Why
Read anythingGETyes (safe)cacheable, retried freely by browsers and SDKs
Change some settingsPATCH /settings (merge patch)yes if absolute values"volume is 35" is idempotent; "volume +5" isn't. See Partial updates
Replace a whole sectionPUT /settings/audioyesonly safe for clients that know every field (see the old-client trap in the next topic)
Set a runtime statePUT /audio/mute with trueyesa toggle (POST /audio/toggle-mute) flips back on retry
Place a callPOST /callsnoadd Idempotency-Key, or the retry dials twice
Hang upDELETE /calls/{id}yessecond attempt returns 404: treat as done
Reboot, factory reset, start an updatePOST → 202 + operationnoadd Idempotency-Key, or let the client PUT /operations/{clientChosenId}
Run a diagnostic (ping, traceroute)POST /diagnostics/ping → result or 202no, but harmlessrunning twice wastes time but changes nothing; no key needed

The Idempotency-Key header itself is specified by an IETF Internet-Draft, not yet an RFC: draft-ietf-httpapi-idempotency-key-header-08 (22 October 2026, the current revision on the IETF datatracker), which recommends 400 when a required key is missing, 422 when a key is reused with a different payload, and 409 when a request arrives while the first one with that key is still being processed. The System Design course covers the server-side store, fingerprints and expiry in Idempotency keys for APIs. Two device-specific notes: the key store has to survive the reboot it caused (keep it in flash with the operation record, or a retry after the reboot schedules another one), and a device has little storage, so keep keys for minutes to hours, not the 24 hours a payment API might.

AdvancedStatus codes that depend on the device's state

Device APIs return a few codes that ordinary web back ends rarely do, because the answer depends on what the hardware is doing right now:

device-state-codes.mjs
import express from "express";
import { once } from "node:events";

// Status codes that depend on what the device is doing right now.
const device = { phase: "booting", callState: "idle" };
const problem = (res, status, title, extra = {}) =>
  res.status(status).type("application/problem+json").json({ title, status, ...extra });

const app = express();
app.use(express.json());
app.use("/api/v1", (req, res, next) => {
  // While services start, the HTTP server is up but the API isn't ready yet.
  if (device.phase === "booting") return res.set("Retry-After", "5").status(503).json({ title: "Device is starting" });
  next();
});
app.route("/api/v1/status")
  .get((req, res) => res.json(device))
  .all((req, res) => res.set("Allow", "GET, HEAD").status(405).json({ title: "Method Not Allowed" }));
app.post("/api/v1/firmware-updates", (req, res) => {
  if (device.callState === "in-call")
    return problem(res, 409, "Device is in a call", { detail: "Retry after the call ends or schedule the update." });
  res.status(202).location("/api/v1/operations/op-1").json({ id: "op-1", status: "queued" });
});
app.post("/api/v1/legacy/reset", (req, res) => res.json({ ok: true })); // only POST is routed here

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}/api/v1`;
// Express's built-in 404 page is HTML; show just its message.
const summary = (text) => text.match(/<pre>(.*)<\/pre>/)?.[1] ?? text;
async function show(method, path) {
  const res = await fetch(base + path, { method, headers: { "content-type": "application/json" }, body: method === "GET" ? undefined : "{}" });
  const extra = ["retry-after", "allow", "location"].filter((h) => res.headers.has(h)).map((h) => `  ${h}: ${res.headers.get(h)}`);
  console.log(`${device.phase}/${device.callState}  ${method} ${path} -> ${res.status} ${summary(await res.text())}${extra.join("")}`);
}

await show("GET", "/status");
device.phase = "ready";
await show("GET", "/status");
await show("DELETE", "/status");
await show("GET", "/legacy/reset");
device.callState = "in-call";
await show("POST", "/firmware-updates");
device.callState = "idle";
await show("POST", "/firmware-updates");
server.close();
Result
booting/idle  GET /status -> 503 {"title":"Device is starting"}  retry-after: 5
ready/idle  GET /status -> 200 {"phase":"ready","callState":"idle"}
ready/idle  DELETE /status -> 405 {"title":"Method Not Allowed"}  allow: GET, HEAD
ready/idle  GET /legacy/reset -> 404 Cannot GET /api/v1/legacy/reset
ready/in-call  POST /firmware-updates -> 409 {"title":"Device is in a call","status":409,"detail":"Retry after the call ends or schedule the update."}
ready/idle  POST /firmware-updates -> 202 {"id":"op-1","status":"queued"}  location: /api/v1/operations/op-1
What’s happening
  1. While phase is "booting", a middleware in front of every /api/v1 route answers 503 Service Unavailable with Retry-After: 5. On a real device the HTTP server often comes up seconds before the services behind it (the call stack, the configuration store), and 503 + Retry-After is the honest answer: "I exist, I can't serve you yet, ask again in 5 s." RFC 9110 defines Retry-After for exactly this ("how long the service is expected to be unavailable"). Returning 500 or 404 here sends clients into error handling instead of waiting.
  2. Once phase is "ready", GET /status works. DELETE /status hits the .all() handler on the route and gets 405 Method Not Allowed with Allow: GET, HEAD: the resource exists, the method doesn't. Partners use 405 + Allow to discover what a resource supports.
  3. GET /legacy/reset shows Express's default for a method nobody routed: 404 Cannot GET /api/v1/legacy/reset, an HTML page. Express (5.3 here) doesn't produce 405 on its own, because it matches method and path together; if you want 405, declare the route with app.route() and add an .all() fallback as above.
  4. POST /firmware-updates while callState is "in-call" returns 409 Conflict: the request is fine, but it conflicts with the device's current state, and the client can fix that (wait, or schedule the update). RFC 9110 describes 409 as a conflict "with the current state of the target resource" that "the user might be able to resolve". The body is a problem details object saying what to do (the full format is in Errors with problem details).
  5. With the call over, the same request returns 202 Accepted and a Location for the operation. Same request, three different correct answers (503, 409, 202), all decided by the device's state, which is why partners need these codes documented and stable.

The codes worth knowing for devices, beyond the usual 200/201/204/400/404:

CodeDevice meaningWhat the client should do
202 Acceptedreboot, update, factory reset, long diagnostic queuedpoll the operation in Location
401 / 403not logged in / logged in but this role can't (e.g. a "user" account changing network settings)log in again / don't retry
405 + Allowresource exists, method doesn't (e.g. DELETE /status)fix the client
409 Conflictdevice busy: in a call, update running, another operation holds a lockwait or schedule; read the problem body
412 Precondition FailedIf-Match didn't match: someone changed it since you read itre-read, re-apply, retry (ETags)
413 Content Too Largeupload or body over the device's limitsend less, or use the upload endpoint
415 Unsupported Media Typewrong Content-Type (e.g. JSON Patch sent where merge patch is expected)read Accept-Patch / Accept
422 Unprocessable Contentwell-formed but invalid: volume 150, hostname with spacesfix the values listed in the body
428 Precondition Requiredthis resource requires If-Matchread it, send the ETag
429 Too Many Requestsrate limited (constrained CPU)wait Retry-After
503 + Retry-Afterbooting, updating, shutting downwait and retry; don't alert yet
AdvancedThe device's own reboot loses the response
Which status code for "update firmware" while a call is active?

A fleet tool sends POST /api/v1/firmware-updates to a room that's in a call. Candidates: 202, 409, 423 Locked, 503. Which one, and why not the others?

Show answer

409 Conflict, with a problem body that says the device is in a call and how to proceed (retry later, or schedule for a maintenance window).

What’s happening
  1. 202 Accepted would accept the update and either cut the meeting off or leave the update queued without the caller knowing when it'll run. Accepting is a valid design (queue it to run when the call ends) but then say so in the operation's status ("waiting": "call-in-progress"); don't silently delay.
  2. 503 Service Unavailable means the service can't handle requests right now (booting, overloaded). The device is perfectly able to answer; it's this particular request that conflicts with what's happening, so 503 would send clients into generic back-off.
  3. 423 Locked comes from WebDAV (RFC 4918), where it means the resource is locked, as part of WebDAV's explicit LOCK/UNLOCK model. It's tempting, but generic clients and SDKs don't know it, and there's no lock here that a client took or could release.
  4. 409 is defined for "a conflict with the current state of the target resource" that "the user might be able to resolve", which is exactly this. Add a stable type URI (for example .../problems/device-busy) so the fleet tool can recognise it and reschedule automatically.

#Partial updates: PATCH and JSON Merge Patch

Added

A device's settings document is big (dozens to hundreds of values across audio, video, network, provisioning, security) and most changes touch one or two of them. PUT replaces the whole resource, so a client that wants to change the default volume has to send everything else back unchanged, which is wasteful and, as you'll see below, dangerous when the client is older than the firmware. PATCH (RFC 5789, March 2010) exists for this: it sends a patch document describing changes, and the server applies them. RFC 5789 requires that "the server MUST apply the entire set of changes atomically": either the whole patch applies or none of it does.

PATCH doesn't say what a patch document looks like; the Content-Type does. Two standard formats exist. JSON Merge Patch (RFC 7396, October 2014, media type application/merge-patch+json) uses "a syntax that closely mimics the document being modified" (RFC 7396 section 1): send { "audio": { "defaultVolume": 35 } } and that member changes; send null for a member to remove it. JSON Patch (RFC 6902, April 2013, application/json-patch+json) is a list of operations (add, remove, replace, move, copy, test) addressed by JSON Pointer paths. The mental model: merge patch is a sticky note with the new values written on it; JSON Patch is a list of edit instructions. The sticky note is what admin UIs and scripts naturally produce, and it's what most device settings APIs should accept. The instructions are for the cases the sticky note can't express.

Here is merge patch exactly as RFC 7396 section 2 defines it, run against device settings and then against the RFC's own test table:

merge-patch.mjs
// RFC 7396 JSON Merge Patch, straight from the RFC's pseudocode (section 2).
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);

function mergePatch(target, patch) {
  if (!isObject(patch)) return patch;               // not an object: replace the whole target
  const result = isObject(target) ? { ...target } : {};
  for (const [name, value] of Object.entries(patch)) {
    if (value === null) delete result[name];        // null means "remove this member"
    else result[name] = mergePatch(result[name], value);
  }
  return result;
}

const settings = {
  audio: { defaultVolume: 50, muteOnJoin: false },
  network: { hostname: "room-12", ntpServers: ["ntp1.example.com", "ntp2.example.com"] },
  provisioning: { serverUrl: "https://prov.example.com" },
};
const cases = [
  ["change one nested value", { audio: { defaultVolume: 35 } }],
  ["remove a member with null", { provisioning: { serverUrl: null } }],
  ["arrays are replaced whole", { network: { ntpServers: ["ntp3.example.com"] } }],
  ["null under a missing parent", { display: { overlay: null } }],
  ["a non-object patch replaces it all", ["oops"]],
];
for (const [label, patch] of cases) {
  const result = mergePatch(settings, patch);
  // Print only the sections the patch touched, so the change is easy to see.
  const shown = isObject(patch) ? Object.fromEntries(Object.keys(patch).map((k) => [k, result[k]])) : result;
  console.log(`${label.padEnd(35)} ${JSON.stringify(patch)}\n${"".padEnd(35)} -> ${JSON.stringify(shown)}`);
}
console.log(`original untouched: ${JSON.stringify(settings.audio)}`);

// The RFC's own test table (Appendix A): original, patch, expected result.
const appendixA = [
  [{ a: "b" }, { a: "c" }, { a: "c" }], [{ a: "b" }, { b: "c" }, { a: "b", b: "c" }],
  [{ a: "b" }, { a: null }, {}], [{ a: "b", b: "c" }, { a: null }, { b: "c" }],
  [{ a: ["b"] }, { a: "c" }, { a: "c" }], [{ a: "c" }, { a: ["b"] }, { a: ["b"] }],
  [{ a: { b: "c" } }, { a: { b: "d", c: null } }, { a: { b: "d" } }], [{ a: [{ b: "c" }] }, { a: [1] }, { a: [1] }],
  [["a", "b"], ["c", "d"], ["c", "d"]], [{ a: "b" }, ["c"], ["c"]], [{ a: "foo" }, null, null],
  [{ a: "foo" }, "bar", "bar"], [{ e: null }, { a: 1 }, { e: null, a: 1 }],
  [[1, 2], { a: "b", c: null }, { a: "b" }], [{}, { a: { bb: { ccc: null } } }, { a: { bb: {} } }],
];
const passed = appendixA.filter(([t, p, want]) => JSON.stringify(mergePatch(t, p)) === JSON.stringify(want)).length;
console.log(`RFC 7396 Appendix A: ${passed}/${appendixA.length} cases pass`);
Result
change one nested value             {"audio":{"defaultVolume":35}}
                                    -> {"audio":{"defaultVolume":35,"muteOnJoin":false}}
remove a member with null           {"provisioning":{"serverUrl":null}}
                                    -> {"provisioning":{}}
arrays are replaced whole           {"network":{"ntpServers":["ntp3.example.com"]}}
                                    -> {"network":{"hostname":"room-12","ntpServers":["ntp3.example.com"]}}
null under a missing parent         {"display":{"overlay":null}}
                                    -> {"display":{}}
a non-object patch replaces it all  ["oops"]
                                    -> ["oops"]
original untouched: {"defaultVolume":50,"muteOnJoin":false}
RFC 7396 Appendix A: 15/15 cases pass
What’s happening
  1. mergePatch is the RFC's pseudocode line for line. If the patch isn't an object, it replaces the target. Otherwise, for each member: null deletes it, anything else is merged recursively into the target's member (or into {} if there wasn't one). It copies ({ ...target }) rather than mutating, which is why original untouched still shows volume 50 at the end.
  2. Change one nested value: { audio: { defaultVolume: 35 } } recurses into audio and replaces only defaultVolume; muteOnJoin: false stays. Nothing else in the document is mentioned, so nothing else changes. This is the case that makes merge patch pleasant: the patch is just the part of the document you edited.
  3. Remove with null: { provisioning: { serverUrl: null } } deletes serverUrl, leaving provisioning: {}. That's how you "clear" a setting in merge patch, and it's also its main limitation: you can't set a value to null, because null always means "remove".
  4. Arrays are replaced whole: sending ntpServers: ["ntp3.example.com"] doesn't append; it replaces the two-element list with a one-element list. RFC 7396 says plainly that it isn't possible to patch part of an array. To add one NTP server the client must send the complete new list, which is a lost-update risk if two admins do it at once (the ETag topic fixes that).
  5. Null under a missing parent creates the parent: display didn't exist, mergePatch(undefined, { overlay: null }) starts from {} and removes nothing, so the result has display: {}. A non-object patch (["oops"]) replaces the entire settings document. A server must validate the result to catch both.
  6. The last line runs all 15 cases from RFC 7396 Appendix A against this function and they pass, so the behaviour above is the standard's, not an interpretation.
AdvancedA PATCH endpoint that validates the result

The server side has three jobs: accept only the patch format it supports, apply the patch to a copy, and validate the resulting document, not the patch, before swapping it in. Validating the patch alone misses removals (null on a required field), typos in keys and cross-field rules. This version uses Zod 4 for the schema:

patch-settings.mjs
import express from "express";
import { z } from "zod";
import { once } from "node:events";

const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const mergePatch = (target, patch) => {
  if (!isObject(patch)) return patch;
  const result = isObject(target) ? { ...target } : {};
  for (const [k, v] of Object.entries(patch)) v === null ? delete result[k] : (result[k] = mergePatch(result[k], v));
  return result;
};

// The schema describes a complete, valid settings document. Strict: unknown keys are errors.
const Settings = z.strictObject({
  audio: z.strictObject({ defaultVolume: z.int().min(0).max(100), muteOnJoin: z.boolean() }),
  network: z.strictObject({ hostname: z.string().regex(/^[a-z0-9-]{1,63}$/), ntpServers: z.array(z.string()).max(4) }),
});
let settings = { audio: { defaultVolume: 50, muteOnJoin: false }, network: { hostname: "room-12", ntpServers: ["ntp1.example.com"] } };

const app = express();
app.patch("/api/v1/settings", express.json({ type: "application/merge-patch+json" }), (req, res) => {
  if (!req.is("application/merge-patch+json"))
    return res.set("Accept-Patch", "application/merge-patch+json").status(415).json({ title: "Unsupported Media Type" });
  const candidate = mergePatch(settings, req.body);   // apply to a copy...
  const check = Settings.safeParse(candidate);        // ...validate the RESULT, not the patch...
  if (!check.success) {
    const errors = check.error.issues.map((i) => ({ pointer: `#/${i.path.join("/")}`, detail: i.message }));
    return res.status(422).type("application/problem+json").json({ title: "Invalid settings", status: 422, errors });
  }
  settings = check.data;                              // ...and only then swap it in, all or nothing.
  res.json(settings);
});

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const url = `http://127.0.0.1:${server.address().port}/api/v1/settings`;
async function patch(label, body, type = "application/merge-patch+json") {
  const res = await fetch(url, { method: "PATCH", headers: { "content-type": type }, body: JSON.stringify(body) });
  const accept = res.headers.get("accept-patch");
  console.log(`${label}: ${JSON.stringify(body)}\n  ${res.status} ${await res.text()}${accept ? `  Accept-Patch: ${accept}` : ""}`);
}

await patch("plain JSON content type", { audio: { defaultVolume: 35 } }, "application/json");
await patch("valid merge patch", { audio: { defaultVolume: 35 } });
await patch("one good, one bad change", { audio: { muteOnJoin: true }, network: { hostname: "Room 12!" } });
await patch("typo in a key", { audio: { defaultVolum: 20 } });
await patch("null on a required member", { network: { ntpServers: null } });
console.log(`final: ${JSON.stringify(settings)}`);
server.close();
Result
plain JSON content type: {"audio":{"defaultVolume":35}}
  415 {"title":"Unsupported Media Type"}  Accept-Patch: application/merge-patch+json
valid merge patch: {"audio":{"defaultVolume":35}}
  200 {"audio":{"defaultVolume":35,"muteOnJoin":false},"network":{"hostname":"room-12","ntpServers":["ntp1.example.com"]}}
one good, one bad change: {"audio":{"muteOnJoin":true},"network":{"hostname":"Room 12!"}}
  422 {"title":"Invalid settings","status":422,"errors":[{"pointer":"#/network/hostname","detail":"Invalid string: must match pattern /^[a-z0-9-]{1,63}$/"}]}
typo in a key: {"audio":{"defaultVolum":20}}
  422 {"title":"Invalid settings","status":422,"errors":[{"pointer":"#/audio","detail":"Unrecognized key: \"defaultVolum\""}]}
null on a required member: {"network":{"ntpServers":null}}
  422 {"title":"Invalid settings","status":422,"errors":[{"pointer":"#/network/ntpServers","detail":"Invalid input: expected array, received undefined"}]}
final: {"audio":{"defaultVolume":35,"muteOnJoin":false},"network":{"hostname":"room-12","ntpServers":["ntp1.example.com"]}}
What’s happening
  1. The route's body parser is express.json({ type: "application/merge-patch+json" }). Express's JSON parser only parses application/json by default, so without the type option a correct merge patch arrives with req.body undefined (verified on Express 5.3 and body-parser 2.4 while writing this). Here the opposite case shows: the first request says application/json, the parser skips it, and the handler answers 415 Unsupported Media Type with Accept-Patch: application/merge-patch+json, which is what RFC 5789 asks for: tell the client which patch formats you take.
  2. The valid merge patch changes defaultVolume to 35. mergePatch builds a candidate, Settings.safeParse checks the complete candidate, and only then does settings = check.data replace the stored document. The response is the full new settings, so the client doesn't need a second GET.
  3. "One good, one bad change" sets muteOnJoin: true and an invalid hostname. The candidate fails on #/network/hostname, the handler returns 422 and never assigns settings, so muteOnJoin isn't changed either. That's the atomicity RFC 5789 requires, and it falls out naturally from "patch a copy, validate, swap".
  4. The typo defaultVolum is caught because the schema uses z.strictObject: an unknown key is an error ("Unrecognized key"), not silently ignored. On a device this matters, because a silently ignored typo looks to the admin like a setting that "doesn't work".
  5. ntpServers: null removes the member (that's merge patch), so the candidate has no ntpServers and the schema reports it as missing. The error format is the errors extension from RFC 9457's own validation example, with JSON Pointers (#/network/ntpServers), so a UI can put each message next to the right field. The final line shows that only the valid change was ever stored.
AdvancedJSON Patch for what merge patch can't say

Merge patch can't touch one element of an array, can't set null, and can't say "only if the current value is X". JSON Patch (RFC 6902) can, at the cost of a less friendly format. Each operation has an op and a path (a JSON Pointer, RFC 6901), and the whole list is atomic: RFC 6902 section 5 says that if an operation fails, "application of the entire patch document SHALL NOT be deemed successful", and because PATCH is atomic, nothing changes. This is a small implementation of four of the six operations:

json-patch.mjs
// A small RFC 6902 JSON Patch: add, remove, replace and test, applied all-or-nothing.
const unescape = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");      // RFC 6901 JSON Pointer
const parse = (pointer) => pointer.split("/").slice(1).map(unescape);

function applyPatch(doc, ops) {
  const copy = structuredClone(doc);                   // work on a copy: one failure undoes everything
  for (const [i, op] of ops.entries()) {
    const keys = parse(op.path);
    const last = keys.pop();
    const parent = keys.reduce((node, k) => node?.[k], copy);
    if (parent === undefined) throw new Error(`op ${i}: path ${op.path} does not exist`);
    const isArray = Array.isArray(parent);
    const index = last === "-" ? parent.length : Number(last);
    if (op.op === "test") {
      if (JSON.stringify(parent[last]) !== JSON.stringify(op.value)) throw new Error(`op ${i}: test failed at ${op.path}`);
    } else if (op.op === "add") {
      isArray ? parent.splice(index, 0, op.value) : (parent[last] = op.value);   // arrays: insert, not overwrite
    } else if (op.op === "replace" || op.op === "remove") {
      if (!(last in parent)) throw new Error(`op ${i}: nothing at ${op.path}`);
      if (op.op === "replace") parent[last] = op.value;
      else isArray ? parent.splice(index, 1) : delete parent[last];
    } else throw new Error(`op ${i}: unsupported op ${op.op}`);
  }
  return copy;
}

const settings = { network: { ntpServers: ["ntp1.example.com", "ntp2.example.com"] }, labels: { "room/floor": "3" } };
const run = (label, ops) => {
  try {
    console.log(`${label}\n  -> ${JSON.stringify(applyPatch(settings, ops))}`);
  } catch (err) {
    console.log(`${label}\n  -> rejected (${err.message}); settings unchanged`);
  }
};

run("replace the second NTP server", [{ op: "replace", path: "/network/ntpServers/1", value: "ntp9.example.com" }]);
run("append with -, insert at 0", [
  { op: "add", path: "/network/ntpServers/-", value: "ntp3.example.com" },
  { op: "add", path: "/network/ntpServers/0", value: "ntp0.example.com" },
]);
run("key containing a slash (~1)", [{ op: "replace", path: "/labels/room~1floor", value: "4" }]);
run("test guards the change", [
  { op: "test", path: "/network/ntpServers/0", value: "ntp1.example.com" },
  { op: "remove", path: "/network/ntpServers/0" },
]);
run("failing test aborts the whole patch", [
  { op: "remove", path: "/network/ntpServers/1" },
  { op: "test", path: "/network/ntpServers/0", value: "ntp7.example.com" },
]);
console.log(`stored: ${JSON.stringify(settings.network)}`);
Result
replace the second NTP server
  -> {"network":{"ntpServers":["ntp1.example.com","ntp9.example.com"]},"labels":{"room/floor":"3"}}
append with -, insert at 0
  -> {"network":{"ntpServers":["ntp0.example.com","ntp1.example.com","ntp2.example.com","ntp3.example.com"]},"labels":{"room/floor":"3"}}
key containing a slash (~1)
  -> {"network":{"ntpServers":["ntp1.example.com","ntp2.example.com"]},"labels":{"room/floor":"4"}}
test guards the change
  -> {"network":{"ntpServers":["ntp2.example.com"]},"labels":{"room/floor":"3"}}
failing test aborts the whole patch
  -> rejected (op 1: test failed at /network/ntpServers/0); settings unchanged
stored: {"ntpServers":["ntp1.example.com","ntp2.example.com"]}
What’s happening
  1. parse splits a JSON Pointer on / and unescapes each token: ~1 becomes / and ~0 becomes ~, in that order, as RFC 6901 requires. applyPatch works on a structuredClone of the document, so a failure part-way through leaves the original untouched.
  2. replace at /network/ntpServers/1 changes only the second element, which merge patch can't do. add at /network/ntpServers/- appends (- means "after the last element"), and add at index 0 inserts, shifting the others right: the result has four servers, ntp0 first.
  3. A key that contains a slash, "room/floor", is addressed as /labels/room~1floor. Settings keys from a provisioning system sometimes contain / or ~, and forgetting the escaping is a classic client bug.
  4. test is a precondition inside the patch: "only remove element 0 if it's still ntp1.example.com". It passes, the remove runs, and ntp2.example.com is the only server left. This is a field-level form of optimistic concurrency, useful when two tools edit the same list.
  5. In the last patch the remove runs first on the copy, then the test fails (element 0 isn't ntp7.example.com), so applyPatch throws and the copy is thrown away: "settings unchanged", and the stored line confirms both servers are still there. (The model compares values with JSON.stringify, which is stricter than RFC 6902 for objects, where member order doesn't matter; fine for this demo, not for a library.)
JSON Merge Patch (RFC 7396)JSON Patch (RFC 6902)
Media typeapplication/merge-patch+jsonapplication/json-patch+json
Looks likethe part of the document that changeda list of { op, path, value } operations
Arraysreplaced wholeadd, remove or replace one element
Setting nullimpossible (null deletes)replace with null works
Conditionsnone (use If-Match)test operation
Easy to write by hand / from a formyesno
Best fordevice settings, admin UIs, scriptslists edited concurrently, nullable fields, generated diffs

Many device APIs accept merge patch only, and that's a reasonable choice: model lists that people edit one item at a time (directory entries, provisioning servers) as collections with their own ids instead of arrays inside the settings, and the main reason for JSON Patch goes away.

AdvancedOld clients' PUT deletes new settings

PUT from an old client deletes what it never knew

This is the device-specific reason to prefer PATCH for settings. A fleet runs many firmware versions, and the tools that manage it are updated on a different schedule. When firmware 4.3 adds a new setting, every older client that does "GET, change one field, PUT the whole thing back" will silently remove it:

old-client-put.mjs
// Firmware 4.3 added display.autoWake. A management tool written for 4.2 still talks to the device.
let onDevice = { display: { brightness: 80, autoWake: true } };   // an admin turned autoWake on in the web UI

// The 4.2 tool maps responses into its own model, which only knows the fields it was built with.
const toToolModel = (json) => ({ display: { brightness: json.display.brightness } });

// PUT: "here is the whole resource". The tool sends back its model, minus what it never knew about.
const put = (body) => (onDevice = body);
// PATCH (merge patch): "change only what I mention".
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const mergePatch = (target, patch) => {
  if (!isObject(patch)) return patch;
  const result = isObject(target) ? { ...target } : {};
  for (const [k, v] of Object.entries(patch)) v === null ? delete result[k] : (result[k] = mergePatch(result[k], v));
  return result;
};

const model = toToolModel(onDevice);   // GET, then the tool's user changes brightness to 60
model.display.brightness = 60;
put(model);
console.log(`after the old tool's PUT:   ${JSON.stringify(onDevice)}`);

onDevice = { display: { brightness: 80, autoWake: true } };
onDevice = mergePatch(onDevice, { display: { brightness: 60 } });
console.log(`after the old tool's PATCH: ${JSON.stringify(onDevice)}`);
Result
after the old tool's PUT:   {"display":{"brightness":60}}
after the old tool's PATCH: {"display":{"brightness":60,"autoWake":true}}
What’s happening
  1. The device runs firmware 4.3, which has display.autoWake, and an admin has turned it on in the web UI, so the stored settings are { brightness: 80, autoWake: true }.
  2. The management tool was built for 4.2. Like most typed clients, it maps the JSON it reads into its own model (toToolModel), which only has brightness. Unknown members are dropped at that moment, before the user does anything.
  3. The user changes brightness to 60, and the tool sends its model back with PUT, which means "this is the resource now". The device stores { brightness: 60 }: autoWake is gone, and nobody asked to turn it off. Nothing errored, nothing logged a conflict, and the admin who set autoWake will find it off next week.
  4. The same tool using merge patch sends only { display: { brightness: 60 } }. Members it doesn't mention are left alone, so autoWake: true survives. PATCH is naturally forward-compatible: an old client can't damage fields it doesn't know.
  5. If you must support PUT, the safe rules are: the server treats missing members as "unchanged" (which makes it a patch in all but name), or clients are required to round-trip unknown members, or PUT requires If-Match and a schema version the client declares. Each of these is harder to get right across partner teams than simply accepting merge patch. The general rules are in Rules for compatible change.
AdvancedValidating the patch instead of the result

#Long-running operations

Added

Some device actions take minutes and outlive the HTTP connection that asked for them: a firmware update downloads hundreds of megabytes, verifies a signature, writes to flash and reboots; a factory reset wipes storage and reboots; a log bundle takes a while to collect; a network diagnostic runs for 30 seconds. Holding the request open for all of that doesn't work: proxies and load balancers time out, the device reboots and kills the connection anyway, and the web UI wants to show progress, not a spinner that might be lying.

The standard answer is to turn the action into a resource. The client POSTs the request; the device answers at once with 202 Accepted, a Location header and a body describing an operation: its id, what it is, its status and progress. The client then GETs that operation until it reaches a final state (succeeded, failed or cancelled), or cancels it. RFC 9110 defines 202 in exactly these terms: the request "has been accepted for processing, but the processing has not been completed", and the response "ought to describe the request's current status and point to (or embed) a status monitor". The mental model is a ticket at a repair shop: you hand the device over, get a ticket number at once, and ring up with the number to ask how it's going; nobody expects you to stand at the counter for two hours.

Google's API design guide formalises the same pattern as AIP-151, "Long-running operations": an operation with a name, a done flag, metadata for progress, and either a response or an error; it suggests about 10 seconds as a rough threshold for when a call deserves one (google.aip.dev/151, checked October 2026). Devices add one twist that cloud APIs don't have: the operation often ends with the server itself disappearing, so the operation record has to live somewhere that survives the reboot.

client device running powered off booting ready POST /reboot 202 + Location running ✕ ECONNREFUSED 503 Retry-After succeeded flash: operation op-1 { kind: reboot, status } survives the power cycle on boot, the device marks it succeeded (or failed) before the API opens
A reboot operation. Errors and 503s in the middle are expected; the client keeps polling until the operation itself reports a final state.

The firmware update first, with a fake clock so the output is the same on every run:

firmware-operation.mjs
import express from "express";
import { once } from "node:events";

// A firmware update is a resource with a lifecycle. Time is a fake clock: tick() = 10 seconds.
const STEPS = ["queued", "downloading", "verifying", "installing", "rebooting"];
const CANCELLABLE = new Set(["queued", "downloading", "verifying"]);   // nothing written to flash yet
const operations = new Map();
let nextId = 1;
const active = () => [...operations.values()].find((op) => op.status === "running");

function tick() {
  const op = active();
  if (!op) return;
  if (op.step === "downloading" && op.percent < 100) return void (op.percent += 50);
  const next = STEPS.indexOf(op.step) + 1;
  if (next < STEPS.length) op.step = STEPS[next];
  else Object.assign(op, { status: "succeeded", step: "done", result: { firmware: op.target } });
}

const app = express();
app.use(express.json());
app.post("/api/v1/firmware-updates", (req, res) => {
  const busy = active();
  if (busy) return res.status(409).json({ title: "Update already in progress", operation: `/api/v1/operations/${busy.id}` });
  const op = { id: `op-${nextId++}`, kind: "firmware-update", target: req.body.version, status: "running", step: "queued", percent: 0 };
  operations.set(op.id, op);
  res.status(202).location(`/api/v1/operations/${op.id}`).json(op);
});
app.get("/api/v1/operations/:id", (req, res) => {
  const op = operations.get(req.params.id);
  if (!op) return res.status(404).json({ title: "Not Found" });
  if (op.status === "running") res.set("Retry-After", "10");   // tell pollers how often is useful
  res.json(op);
});
app.post("/api/v1/operations/:id/cancel", (req, res) => {
  const op = operations.get(req.params.id);
  if (op.status !== "running" || !CANCELLABLE.has(op.step))
    return res.status(409).json({ title: "Cannot cancel", detail: `operation is ${op.status}/${op.step}` });
  Object.assign(op, { status: "cancelled" });
  res.json(op);
});

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
const send = async (method, path, body) => {
  const res = await fetch(base + path, { method, headers: { "content-type": "application/json" }, body: body && JSON.stringify(body) });
  const json = await res.json();
  const h = ["location", "retry-after"].filter((n) => res.headers.has(n)).map((n) => ` ${n}=${res.headers.get(n)}`).join("");
  const view = json.status ? `${json.id} ${json.status}/${json.step}${json.step === "downloading" ? ` ${json.percent}%` : ""}` : `${json.title}${json.detail ? ` (${json.detail})` : ""}`;
  console.log(`${method.padEnd(4)} ${path.padEnd(30)} ${res.status} ${view}${h}`);
  return { res, json };
};

const first = await send("POST", "/api/v1/firmware-updates", { version: "4.3.0" });
const opUrl = first.res.headers.get("location");
await send("POST", "/api/v1/firmware-updates", { version: "4.3.0" });   // a second admin clicks too
tick(); tick();
await send("GET", opUrl);
await send("POST", `${opUrl}/cancel`);
await send("POST", "/api/v1/firmware-updates", { version: "4.3.0" });  // try again
for (let polls = 0; polls < 10; polls++) {
  tick();
  const { json } = await send("GET", "/api/v1/operations/op-2");
  if (json.step === "installing") await send("POST", "/api/v1/operations/op-2/cancel");
  if (json.status !== "running") break;
}
server.close();
Result
POST /api/v1/firmware-updates       202 op-1 running/queued location=/api/v1/operations/op-1
POST /api/v1/firmware-updates       409 Update already in progress
GET  /api/v1/operations/op-1        200 op-1 running/downloading 50% retry-after=10
POST /api/v1/operations/op-1/cancel 200 op-1 cancelled/downloading 50%
POST /api/v1/firmware-updates       202 op-2 running/queued location=/api/v1/operations/op-2
GET  /api/v1/operations/op-2        200 op-2 running/downloading 0% retry-after=10
GET  /api/v1/operations/op-2        200 op-2 running/downloading 50% retry-after=10
GET  /api/v1/operations/op-2        200 op-2 running/downloading 100% retry-after=10
GET  /api/v1/operations/op-2        200 op-2 running/verifying retry-after=10
GET  /api/v1/operations/op-2        200 op-2 running/installing retry-after=10
POST /api/v1/operations/op-2/cancel 409 Cannot cancel (operation is running/installing)
GET  /api/v1/operations/op-2        200 op-2 running/rebooting retry-after=10
GET  /api/v1/operations/op-2        200 op-2 succeeded/done
What’s happening
  1. POST /firmware-updates creates op-1 in state running/queued and answers 202 with Location: /api/v1/operations/op-1 and the operation as the body. The work hasn't started; the response just proves the device took the request and gives the client a ticket.
  2. A second admin clicks "Update" at the same moment. active() finds op-1 still running, so the device answers 409 Conflict; the body (not printed) includes operation: "/api/v1/operations/op-1", so the second client can follow the existing update instead of starting another. One update at a time is a device rule, not an HTTP one, and the API should say so explicitly.
  3. Two tick()s (20 fake seconds) move op-1 into downloading at 50%. The GET includes Retry-After: 10: the server is telling pollers how often is useful, so a hundred browser tabs don't each poll every 200 ms. Cancelling now works (200, cancelled/downloading), because nothing has been written to flash yet; CANCELLABLE holds the steps before the point of no return.
  4. A new update, op-2, starts and the client polls once per tick: queued → downloading 0% → 50% → 100% → verifying → installing. When the client sees installing it tries to cancel and gets 409 Cannot cancel (operation is running/installing): interrupting a flash write could leave the device unbootable, so the device refuses rather than pretending.
  5. Two more polls show rebooting and then succeeded/done; Retry-After disappears once the operation is final, and the loop stops because status isn't running. The client never held a connection open for longer than one quick request.
AdvancedThe operation resource

A shape that works across reboots, updates, resets and diagnostics:

Operation resource
{
  "id": "op-2",
  "kind": "firmware-update",
  "status": "running",
  "step": "downloading",
  "percent": 50,
  "target": "4.3.0",
  "createdAt": "2026-10-27T09:14:03Z",
  "updatedAt": "2026-10-27T09:14:23Z",
  "cancellable": true,
  "error": null,
  "result": null
}
  • status is a small closed set (queued/running/succeeded/failed/cancelled) that clients branch on; step is the finer, kind-specific progress (downloading, verifying, installing, rebooting) that UIs display. Keeping them separate lets you add steps in later firmware without breaking clients that only check status.
  • percent only where it's real. A download has a byte count; "installing" often doesn't, and a progress bar stuck at 73% for four minutes erodes trust faster than an honest "Installing (this takes about 5 minutes)".
  • error on failure is a problem details object (next-but-one topic) with a stable type, so "signature invalid" and "not enough storage" can be handled differently. result on success holds the outcome ({ "firmware": "4.3.0" }, or a link to a log bundle).
  • cancellable tells the UI whether to show the Cancel button, instead of letting users click and get 409.
  • Operations are kept for a while after they finish (AIP-151 mentions 30 days as a rule of thumb for cloud APIs); a device with little storage might keep the last 20, but it must keep them across reboots, because the most important poll is the one after the reboot.

How clients find out it's done. Polling with Retry-After is the baseline, because it works through every proxy and from every script. The web UI can also subscribe to an event stream (Server-Sent Events or a WebSocket; see Polling, long polling, SSE and WebSockets for device status) and receive operation.updated events, but it should still be able to fall back to polling, because the stream dies with the reboot too. Cloud-managed devices often report operation progress to the management service, which notifies partners through webhooks.

Cancellation is a POST /operations/{id}/cancel (or :cancel in Google's style), not DELETE /operations/{id}: DELETE would mean "forget this record", and you want to keep the record of a cancelled update. Cancellation can itself take time (stop a download, clean up a partial file), so it may return the operation in a cancelling step before it reaches cancelled.

Prefer: respond-async. RFC 7240 (June 2014) defines a request preference a client can send to say it's happy with a 202 if the work is slow. It's useful for actions that are sometimes quick (a ping that usually takes 1 s but can take 30), where the server can answer 200 with the result when it's fast and 202 with an operation when it isn't.

AdvancedWhen the operation outlives the server

A reboot is the extreme case: the response is the last thing the old process sends. This model really closes the server, polls while nothing is listening, then starts a new server on the same port in a "booting" state:

reboot-operation.mjs
import express from "express";
import { once } from "node:events";

// "flash" survives a reboot; everything else in the process doesn't. The operation record lives in flash,
// so a client that polled before the reboot can still find it afterwards.
const flash = { bootCount: 1, operations: new Map() };

function boot(port) {
  let ready = false;
  for (const op of flash.operations.values())          // finish what the last boot started
    if (op.status === "running" && op.kind === "reboot" && op.bootCount < flash.bootCount)
      Object.assign(op, { status: "succeeded", step: "done" });
  const app = express();
  app.use((req, res, next) => (ready ? next() : res.set("Retry-After", "5").status(503).json({ title: "Device is starting" })));
  app.post("/api/v1/system/reboot", (req, res) => {
    const op = { id: `op-${flash.operations.size + 1}`, kind: "reboot", status: "running", step: "rebooting", bootCount: flash.bootCount };
    flash.operations.set(op.id, op);
    res.status(202).location(`/api/v1/operations/${op.id}`).json(op);
    res.on("finish", () => setImmediate(() => { server.closeAllConnections(); server.close(); }));   // power goes away
  });
  app.get("/api/v1/operations/:id", (req, res) => res.json(flash.operations.get(req.params.id)));
  const server = app.listen(port, "127.0.0.1");
  return { server, markReady: () => (ready = true) };
}

let device = boot(0);
await once(device.server, "listening");
device.markReady();
const port = device.server.address().port;
const base = `http://127.0.0.1:${port}`;

async function poll(path) {
  try {
    const res = await fetch(base + path);
    const body = await res.json();
    console.log(`GET ${path} -> ${res.status} ${body.status ? `${body.status}/${body.step}` : body.title}`);
    return body;
  } catch (err) {
    console.log(`GET ${path} -> ${err.cause?.code} (device is down: keep waiting, don't fail)`);
  }
}

const down = once(device.server, "close");              // listen before the reboot can happen
const res = await fetch(`${base}/api/v1/system/reboot`, { method: "POST" });
const opUrl = res.headers.get("location");
console.log(`POST /api/v1/system/reboot -> ${res.status} Location: ${opUrl}`);
await down;
await poll(opUrl);                                     // nothing is listening
flash.bootCount++;
device = boot(port);                                   // power back on: HTTP is up first...
await once(device.server, "listening");
await poll(opUrl);                                     // ...but the API isn't ready
device.markReady();
await poll(opUrl);
console.log(`boot count now ${flash.bootCount}`);
device.server.close();
Result
POST /api/v1/system/reboot -> 202 Location: /api/v1/operations/op-1
GET /api/v1/operations/op-1 -> ECONNREFUSED (device is down: keep waiting, don't fail)
GET /api/v1/operations/op-1 -> 503 Device is starting
GET /api/v1/operations/op-1 -> 200 succeeded/done
boot count now 2
What’s happening
  1. flash is the only state that survives boot(); each boot creates a brand-new Express app and server, just as a real device starts a new process. The reboot handler stores op-1 (kind: "reboot", bootCount: 1) in flash, answers 202 with the operation's Location, and only on the response's finish event (the bytes are written) closes all connections and the server: "power goes away".
  2. The client registered const down = once(device.server, "close") before sending the request. Without that, the close can happen before the script starts waiting for it and the await hangs forever: the first draft of this model did exactly that.
  3. The first poll gets ECONNREFUSED: nothing is listening on the port. A careless client treats that as "reboot failed" and alerts; this one logs it as expected and keeps waiting. Real clients should keep waiting up to a documented maximum (say, 10 minutes for an update) before declaring the device lost.
  4. boot(port) increments nothing itself: the script bumped flash.bootCount to 2 first, as a bootloader would. During boot, the loop over flash.operations finds op-1, which was started in boot 1 (op.bootCount < flash.bootCount), and marks it succeeded. That's the only way a reboot operation can succeed: the next boot has to record it.
  5. The new server is listening but ready is false, so the poll gets 503 Device is starting with Retry-After: HTTP is up before the API is. Once markReady() runs, the poll returns 200 succeeded/done. The client's job was simple: treat connection errors and 503 as "still in progress", and trust only the operation's own final status.
Advanced202 means accepted, not done
Why not just make the client wait?

A colleague suggests POST /system/reboot should block until the device is back, then return 200. What goes wrong?

Show answer

The connection that would carry the 200 is destroyed by the reboot itself, so the client never gets an answer, and everything in between (proxies, the web UI, SDK timeouts) gives up long before a minute-long reboot finishes.

What’s happening
  1. The process that received the request is the one being restarted. When it goes down, its sockets close, so the 200 has nowhere to be written. The new process after boot has no record of that connection.
  2. Even for operations that don't reboot (a 4-minute log collection), intermediaries time out: browsers, reverse proxies and load balancers commonly cut idle requests after tens of seconds to a few minutes, and SDKs have their own timeouts. The client sees an error for an operation that's still running fine.
  3. A client that gets an error will often retry, and a retried reboot or update is the double-execution problem from Methods, status codes and idempotency.
  4. 202 + an operation resource in flash fixes all three: the answer is sent while the connection is healthy, progress is visible, and the outcome can be read after the reboot by any client, including one that wasn't the original caller.

#Optimistic concurrency with ETags

Added

A device rarely has one admin. The local web UI, the cloud management service, a provisioning server that re-applies a profile every night, a room-control system and a technician's script can all write the same settings. When two of them read the same version, change it and write it back, the second write silently erases the first: the classic lost update. RFC 9110 names it directly: If-Match exists "to prevent accidental overwrites when multiple user agents might be acting in parallel on the same resource (i.e., to prevent the 'lost update' problem)".

The mechanism is optimistic locking over HTTP. Every response carries an ETag, an opaque version tag for the representation ("r7"). A client that wants to write sends the tag it read in If-Match: "r7". The device compares it with the current tag before doing anything: if they match, nobody changed it in between, so apply the write and return the new tag; if they don't, refuse with 412 Precondition Failed, and the client re-reads, re-applies its change to the fresh copy and tries again. A device can go further and require the header, answering 428 Precondition Required (RFC 6585, April 2012) to writes without it. The mental model is a shared document with a version number in the corner: "save my edit, but only if it's still version 7".

It's called optimistic because nobody takes a lock: conflicts are assumed to be rare and detected when they happen, rather than prevented by making everyone wait. That suits devices well. A lock held by a management service that then loses its network connection would block the local admin until it timed out; an ETag check costs one string comparison and holds nothing. AWS IoT's Device Shadow uses the same idea with a version number: an update that carries a version is processed "only if the specified version matches the latest version it has" (AWS IoT Developer Guide, checked October 2026).

web UI device fleet service GET → ETag "r1" GET → ETag "r1" PATCH If-Match "r1" 200, ETag "r2" PATCH If-Match "r1" 412 (now "r2") GET → ETag "r2" PATCH If-Match "r2" 200, ETag "r3" both changes kept: the fleet re-applied its edit to "r2"
The second writer loses the race, but not its change: 412 makes it re-read and re-apply instead of overwriting.

The model runs the race twice: once with no preconditions, once with If-Match required. Both admins add one NTP server, which with merge patch means sending the whole new array:

etag-lost-update.mjs
import express from "express";
import { once } from "node:events";

function startDevice({ requireIfMatch }) {
  let rev = 1;
  let settings = { network: { ntpServers: ["ntp1.example.com"] } };
  const etag = () => `"r${rev}"`;                       // a strong validator: changes on every write
  // RFC 9110 13.1.1: If-Match uses the strong comparison, so a weak tag (W/"...") never matches.
  const matches = (header) => header.trim() === "*" || header.split(",").map((t) => t.trim()).includes(etag());

  const app = express();
  app.get("/api/v1/settings", (req, res) => res.set("ETag", etag()).json(settings));
  app.patch("/api/v1/settings", express.json({ type: "application/merge-patch+json" }), (req, res) => {
    const ifMatch = req.get("if-match");
    if (!ifMatch && requireIfMatch) return res.status(428).json({ title: "Precondition Required", detail: "Send If-Match with the ETag you read" });
    if (ifMatch && !matches(ifMatch)) return res.status(412).set("ETag", etag()).json({ title: "Precondition Failed" });
    settings = { ...settings, network: { ...settings.network, ...req.body.network } };  // merge patch, one level
    rev++;
    res.set("ETag", etag()).json(settings);
  });
  const server = app.listen(0, "127.0.0.1");
  return { server, current: () => settings.network.ntpServers.join(", ") };
}

const read = async (base) => {
  const res = await fetch(`${base}/api/v1/settings`);
  return { doc: await res.json(), etag: res.headers.get("etag") };
};
// An admin adds one NTP server to the list they read and sends the whole new array
// (merge patch replaces arrays), optionally saying "only if it's still the version I read".
async function addServer(base, name, server, { doc, etag }, useIfMatch) {
  const headers = { "content-type": "application/merge-patch+json", ...(useIfMatch && { "if-match": etag }) };
  const body = JSON.stringify({ network: { ntpServers: [...doc.network.ntpServers, server] } });
  const res = await fetch(`${base}/api/v1/settings`, { method: "PATCH", headers, body });
  console.log(`  ${name.padEnd(5)} PATCH ${useIfMatch ? `If-Match ${etag}` : "(no If-Match)"} -> ${res.status} ETag ${res.headers.get("etag")}`);
  return res.status;
}

for (const useIfMatch of [false, true]) {
  const device = startDevice({ requireIfMatch: useIfMatch });
  await once(device.server, "listening");
  const base = `http://127.0.0.1:${device.server.address().port}`;
  console.log(useIfMatch ? "With If-Match:" : "Without preconditions:");
  const uiRead = await read(base);                    // both open the settings at the same time
  const fleetRead = await read(base);
  await addServer(base, "ui", "ntp-ui.example.com", uiRead, useIfMatch);
  const status = await addServer(base, "fleet", "ntp-fleet.example.com", fleetRead, useIfMatch);
  if (status === 412) await addServer(base, "fleet", "ntp-fleet.example.com", await read(base), useIfMatch); // re-read, redo
  if (useIfMatch) {
    const bare = await fetch(`${base}/api/v1/settings`, { method: "PATCH", headers: { "content-type": "application/merge-patch+json" }, body: "{}" });
    console.log(`  a script PATCH with no If-Match -> ${bare.status}`);
  }
  console.log(`  ntpServers: ${device.current()}`);
  device.server.close();
}
Result
Without preconditions:
  ui    PATCH (no If-Match) -> 200 ETag "r2"
  fleet PATCH (no If-Match) -> 200 ETag "r3"
  ntpServers: ntp1.example.com, ntp-fleet.example.com
With If-Match:
  ui    PATCH If-Match "r1" -> 200 ETag "r2"
  fleet PATCH If-Match "r1" -> 412 ETag "r2"
  fleet PATCH If-Match "r2" -> 200 ETag "r3"
  a script PATCH with no If-Match -> 428
  ntpServers: ntp1.example.com, ntp-ui.example.com, ntp-fleet.example.com
What’s happening
  1. startDevice keeps a revision counter rev and derives a strong ETag "r1", "r2"… from it. matches() implements RFC 9110's If-Match rule: * matches any current representation, otherwise any tag in the comma-separated list must equal the current tag exactly (strong comparison). Each successful PATCH increments rev.
  2. Without preconditions: both admins read the settings at the same moment and see ["ntp1.example.com"]. The UI sends [ntp1, ntp-ui] and gets 200, ETag "r2". The fleet service then sends [ntp1, ntp-fleet], computed from its stale read, and also gets 200. The final list has no ntp-ui: the UI's change vanished, and both callers were told they succeeded.
  3. With If-Match: the UI sends If-Match: "r1", which matches, so it gets 200 and "r2". The fleet service sends If-Match: "r1" too, but the current tag is now "r2", so the device refuses with 412 before applying anything and includes the current ETag: "r2" so the client knows it's behind.
  4. The fleet service does what a well-behaved client does on 412: re-reads (getting [ntp1, ntp-ui] and "r2"), re-applies its own intent ("add ntp-fleet") to the fresh copy, and sends If-Match: "r2", which succeeds with "r3". The final list has all three servers. The conflict cost one extra round trip instead of a lost setting.
  5. Because this device was started with requireIfMatch: true, a script that sends PATCH without If-Match gets 428 Precondition Required. Requiring the header is what makes the protection real: if it's optional, the one integration that never sends it can still overwrite everyone else.
AdvancedWhat to use as the ETag

ETags look trivial and hide two traps on devices. This model shows both: Express's automatic ETag, and a revision counter across a factory reset.

etag-choices.mjs
import express from "express";
import { createHash } from "node:crypto";
import { once } from "node:events";

// 1. Express adds an ETag to res.json() by itself, but it's a WEAK one.
const app = express();
app.get("/api/v1/settings", (req, res) => res.json({ audio: { defaultVolume: 50 } }));
const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/settings`);
const expressTag = res.headers.get("etag");
server.close();

// RFC 9110 8.8.3.2: strong comparison = neither tag is weak and the opaque tags match.
const strongMatch = (a, b) => !a.startsWith("W/") && !b.startsWith("W/") && a === b;
console.log(`Express default ETag: ${expressTag}`);
console.log(`If-Match with that tag, strong comparison: ${strongMatch(expressTag, expressTag) ? "match" : "no match -> 412 every time"}`);

// 2. A revision counter vs a content hash, across a factory reset.
const hashTag = (doc) => `"${createHash("sha256").update(JSON.stringify(doc)).digest("base64url").slice(0, 16)}"`;
let rev = 1;
let doc = { audio: { defaultVolume: 50 } };
doc = { audio: { defaultVolume: 20 } }; rev++;               // an admin saves: rev 2
const heldCounter = `"r${rev}"`, heldHash = hashTag(doc);      // a script reads and keeps both tags
console.log(`script read volume 20: counter tag ${heldCounter}, hash tag ${heldHash}`);

rev = 1; doc = { audio: { defaultVolume: 50 } };               // factory reset: counter starts again
doc = { audio: { defaultVolume: 90 } }; rev++;                 // someone sets 90: rev 2 again
console.log(`after reset, volume 90: counter tag "r${rev}", hash tag ${hashTag(doc)}`);
console.log(`script's If-Match ${heldCounter}: ${heldCounter === `"r${rev}"` ? "MATCHES (wrongly accepted)" : "412"}`);
console.log(`script's If-Match ${heldHash}: ${heldHash === hashTag(doc) ? "matches" : "412 (correct: the settings changed)"}`);
Result
Express default ETag: W/"1e-bMMu96t1Fo1ZcOhK2P8Ba1yjfDE"
If-Match with that tag, strong comparison: no match -> 412 every time
script read volume 20: counter tag "r2", hash tag "AWRRd6VEybGMnZ4C"
after reset, volume 90: counter tag "r2", hash tag "-pOZn1FUg9kssHUB"
script's If-Match "r2": MATCHES (wrongly accepted)
script's If-Match "AWRRd6VEybGMnZ4C": 412 (correct: the settings changed)
What’s happening
  1. Express adds an ETag to every res.json() / res.send() body by default, computed from the body, and it's weak: W/"1e-…". That's fine for caching (If-None-Match uses weak comparison), but RFC 9110 13.1.1 says an origin server "MUST use the strong comparison function when comparing entity tags for If-Match", and under strong comparison a weak tag never matches anything, not even itself. A client that copies Express's tag into If-Match gets 412 on every write. Set your own strong ETag header on resources that accept conditional writes.
  2. The second half compares two ways to build a strong tag. A script reads the settings when the volume is 20, at revision 2, and keeps both candidate tags: "r2" from the counter and "AWRRd6VEybGMnZ4C" from a SHA-256 hash of the document.
  3. The device is factory reset. The counter restarts at 1, someone sets the volume to 90, and the counter is at 2 again: the tag is "r2", the same string as before, for a completely different document. The hash tag is "-pOZn1FUg9kssHUB", because the content differs.
  4. The script now sends its old If-Match: "r2". The counter-based check matches, and the script's stale write is accepted on top of settings it never saw. The hash-based check returns 412, correctly. This is the ABA problem: a version number that can repeat isn't a version.
  5. Fixes, in order of preference: derive the tag from the content (hash a canonical serialisation: the model's JSON.stringify depends on key order, so sort keys in real code); or keep a counter that never restarts (store it outside the area a factory reset wipes, or combine it with a boot or reset epoch: "e3-r2"). Either way, the tag must change whenever the representation changes and must never come back for a different one.
AdvancedGranularity, and which resources get ETags

One ETag per resource you write. If /settings has one tag for the whole document, an audio change and a network change by different people conflict even though they touch different fields. Sub-resources (/settings/audio, /settings/network) with their own tags reduce false conflicts; the whole-document /settings can still have a combined tag for export and bulk apply. Status doesn't need If-Match (nobody writes it), but its ETag is still useful for If-None-Match polling (Designing for constrained devices). This is the concrete reason settings and status must be separate resources: an ETag over a document that includes the temperature changes every few seconds, and every conditional write fails.

Return the new ETag on every successful write, so the client can make its next change without a GET. RFC 9110 has a precise rule here for PUT: a server "MUST NOT send a validator field … in a successful response to PUT unless the request's representation data was saved without any transformation". If the device normalises what it stores (lower-cases a hostname, fills defaults), either return the stored representation in the body along with its tag, or leave the tag out so the client re-reads.

RFC 9110 also allows a 2xx instead of 412 when "the state-changing request … appears to have already been applied": a retried PATCH whose first attempt succeeded will fail If-Match (the tag moved on), even though the device already has exactly what the client wanted. The RFC warns this is risky for resources multiple clients change in similar ways; for device settings, simplest is to always return 412 and let the client re-read and see its change is already there.

AdvancedIf-Match with a weak ETag always fails
Does PATCH with merge patch need If-Match at all?

Merge patch only sends the fields you changed. If the UI changes audio.defaultVolume and the fleet service changes network.hostname at the same time, can either change be lost? When does it matter?

Show answer

In that exact case, no: both patches touch different members, both apply, and nothing is lost. It matters whenever two writers touch the same member, and arrays make that much more likely than it looks.

What’s happening
  1. The UI sends { "audio": { "defaultVolume": 35 } } and the fleet sends { "network": { "hostname": "room-12b" } }. Merge patch merges each into the current document at the time it's applied, so the second patch doesn't carry stale values for fields it didn't mention. Both survive.
  2. If both change defaultVolume, the second wins; that's usually acceptable (the last person to decide decides), but the first person isn't told, so a UI that shows "saved" is now wrong.
  3. Arrays are the real trap: adding an NTP server means sending the whole list built from your earlier read. Two "add one item" edits become two "replace the list" writes, and the first item disappears, as in the model above.
  4. Other read-modify-write logic has the same issue: "set brightness to 10% above what it was", or a UI that sends a whole form section it pre-filled from a read. If-Match turns all of these into a detected conflict instead of a silent loss, which is why devices commonly require it on settings writes even though merge patch alone handles disjoint changes.

#Errors with problem details

Added

A device API's errors are read by more programs than people. The partner's room-control system has to decide whether to retry, reschedule or alert; the fleet service has to group ten thousand failures by cause; the web UI has to put "must be 0 to 100" next to the right field. If every endpoint invents its own error JSON ({ "error": "busy" } here, { "code": 17, "msg": "…" } there), each of those programs grows a pile of special cases, and every firmware release risks breaking them by rewording a message.

RFC 9457, Problem Details for HTTP APIs (July 2023, which obsoletes RFC 7807) defines one JSON shape with the media type application/problem+json. Its members: type, a URI that identifies the kind of problem and is "the problem type's primary identifier" (defaulting to about:blank when absent); title, a short human summary of the type that "SHOULD NOT change from occurrence to occurrence"; status, the HTTP status code, which is "only advisory" and must match the real response; detail, a human explanation of this occurrence that consumers "SHOULD NOT parse"; and instance, a URI for this specific occurrence. Types can add extension members, and clients "MUST ignore any such extensions that they don't recognize".

The mental model: type is the error's name in the contract; everything else is the message on the screen. Partners write if (problem.type === ".../device-busy"), and that string must never change. The title can be reworded and translated, the detail can say whatever helps a human fix it, and extensions carry the machine-readable specifics (which call is active, which fields failed). That separation is what lets you improve error messages in firmware 4.3 without breaking an integration written against 4.1.

problem-details.mjs
import express from "express";
import { STATUS_CODES } from "node:http";
import { once } from "node:events";

// One error shape for the whole API (RFC 9457). "type" is the contract partners code against.
const TYPES = "https://dev.example.com/sim100/problems";
class Problem extends Error {
  constructor(status, type, title, extra = {}) {
    super(title);
    Object.assign(this, { status, type: `${TYPES}/${type}`, title, extra });
  }
}
let errorCount = 0;

const app = express();
app.use(express.json());
app.post("/api/v1/calls", (req, res) => {
  const errors = [];
  if (typeof req.body.dialString !== "string") errors.push({ pointer: "#/dialString", detail: "is required" });
  if (req.body.rate !== undefined && ![512, 1024, 2048].includes(req.body.rate))
    errors.push({ pointer: "#/rate", detail: "must be 512, 1024 or 2048" });
  if (errors.length) throw new Problem(422, "validation-error", "Request is not valid", { errors });
  throw new Problem(409, "device-busy", "Device is already in a call", {
    detail: "Only one call at a time on this model. Hang up call-7 first.",
    activeCall: "/api/v1/calls/call-7",
  });
});
app.get("/api/v1/diagnostics/crash", () => {
  throw new TypeError("Cannot read properties of undefined (reading 'sensor')");   // a bug, not a contract
});
app.use((req, res, next) => next(Object.assign(new Error("no route"), { status: 404 })));

app.use((err, req, res, next) => {
  let body;
  if (err instanceof Problem) body = { type: err.type, title: err.title, status: err.status, ...err.extra };
  else {
    const status = err.status ?? 500;                          // body-parser errors carry a status (400, 413...)
    body = { type: "about:blank", title: STATUS_CODES[status], status };   // generic: the status says it all
    if (err.type === "entity.parse.failed") body.detail = "The request body is not valid JSON.";
    if (status === 500) body.instance = `/api/v1/diagnostics/errors/${++errorCount}`;   // log it; never send the stack
  }
  res.status(body.status).type("application/problem+json").json(body);
});

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}/api/v1`;
const show = async (label, path, init) => {
  const res = await fetch(base + path, init);
  console.log(`${label}: ${res.status} ${res.headers.get("content-type")}\n  ${await res.text()}`);
};
const post = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body });

await show("invalid fields", "/calls", post(JSON.stringify({ rate: 999 })));
await show("busy device", "/calls", post(JSON.stringify({ dialString: "sip:ops@example.com" })));
await show("malformed JSON", "/calls", post('{"dialString": '));
await show("unknown path", "/cameras/9", {});
await show("a bug", "/diagnostics/crash", {});
server.close();
Result
invalid fields: 422 application/problem+json; charset=utf-8
  {"type":"https://dev.example.com/sim100/problems/validation-error","title":"Request is not valid","status":422,"errors":[{"pointer":"#/dialString","detail":"is required"},{"pointer":"#/rate","detail":"must be 512, 1024 or 2048"}]}
busy device: 409 application/problem+json; charset=utf-8
  {"type":"https://dev.example.com/sim100/problems/device-busy","title":"Device is already in a call","status":409,"detail":"Only one call at a time on this model. Hang up call-7 first.","activeCall":"/api/v1/calls/call-7"}
malformed JSON: 400 application/problem+json; charset=utf-8
  {"type":"about:blank","title":"Bad Request","status":400,"detail":"The request body is not valid JSON."}
unknown path: 404 application/problem+json; charset=utf-8
  {"type":"about:blank","title":"Not Found","status":404}
a bug: 500 application/problem+json; charset=utf-8
  {"type":"about:blank","title":"Internal Server Error","status":500,"instance":"/api/v1/diagnostics/errors/1"}
What’s happening
  1. Problem is an Error subclass that carries a status, a full type URI built from one base (https://dev.example.com/sim100/problems/…), a title and extension members. Handlers just throw it; Express 5 forwards errors thrown in handlers (and rejected promises from async handlers) to the error middleware, so no route formats errors itself.
  2. Invalid fields: the call request has no dialString and a rate of 999. Both problems are collected and returned together as 422, type …/validation-error, with the errors extension: an array of { pointer, detail } with JSON Pointers into the request body. This is the extension RFC 9457 itself uses as its example, and it's what lets a form show every mistake at once instead of one per submit.
  3. Busy device: type …/device-busy, 409, a detail a person can act on, and the extension activeCall: "/api/v1/calls/call-7" so a program can act too (hang up that call, or wait for it). Note the content-type on every line: application/problem+json; charset=utf-8. The handler calls res.type("application/problem+json") before res.json(), and Express keeps a content type that's already set.
  4. Malformed JSON never reaches a route: Express's body parser throws an error with status: 400 and type: "entity.parse.failed", and the middleware turns it into about:blank with title Bad Request and a fixed, safe detail. RFC 9457 says that with about:blank the title should be the HTTP status phrase, and that generic conditions are "usually better expressed as plain status codes", so a parse error, an unknown path (404 Not Found) and a crash don't get their own types.
  5. A bug: the crash handler throws a TypeError. The response is 500 Internal Server Error with an instance of /api/v1/diagnostics/errors/1 and nothing else: no message, no stack, no file names. RFC 9457's security section warns against leaking implementation details; the instance id lets support staff find the full error in the device's own logs or diagnostic bundle.
AdvancedDesigning the problem types partners rely on

RFC 9457 says each new problem type must document a type URI, a title, and the HTTP status code it's used with, and that a problem type URI "SHOULD resolve to HTML [HTML5] documentation that explains how to resolve the problem". For a device platform that becomes an error catalogue: a page per type, versioned with the API, listing when it occurs, its extension members, and what a client should do. A starter set for a device:

Type (suffix)StatusExtensionsClient action
validation-error422errors[] (pointer, detail)fix the listed fields
device-busy409activeCall or operationwait, or schedule
update-in-progress409operationfollow the operation
restart-required409pendingRestart[]reboot before the next change, if the setting demands it
not-supported-on-model422model, capabilityhide the feature for this model
storage-full507freeBytes, requiredBytesfree space or pick a smaller image
signature-invalid422imagedon't retry; get a valid image
quota-exceeded429violated-policies[]honour Retry-After

The last row reuses a type defined by the RateLimit header fields Internet-Draft (draft-ietf-httpapi-ratelimit-headers-11, 23 May 2026, not yet an RFC), https://iana.org/assignments/http-problem-types#quota-exceeded. The draft asks IANA to register it, but it isn't in IANA's HTTP Problem Types registry yet (checked 27 October 2026), so treat the URI as provisional. Reusing a standard type where one fits is better than inventing your own.

Rules that keep the catalogue stable for years:

  • Types are forever. Never rename or reuse a type URI; adding a new type is a compatible change only if clients have a sensible fallback (they should, by status code). Pick a URI on a domain you'll keep: RFC 9457 notes that switching from a non-resolvable URI to a resolvable one later is itself "a breaking change" because it creates a new identity.
  • Absolute type URIs. The RFC recommends absolute URIs because relative ones resolve against different bases on different endpoints.
  • Localise title and detail, never type. A Japanese admin UI can request Accept-Language: ja and get a translated title; the type stays the same. Better still, let the web UI map types to its own translated strings and use detail only as a fallback.
  • Extension names should be letters, digits and _, starting with a letter, at least three characters (RFC 9457 4), so they survive serialisation in other formats.
  • One error per response, mostly. RFC 9457 recommends that when problems have different types, the response carries "the most relevant or urgent problem". Many validation errors are one problem (validation-error) with many entries, which is fine.
  • Keep the documented codes of an existing API. The Poly VideoOS guide quoted in the first topic, for example, lists 415 for "Payload invalid or missing one or more required fields", where RFC 9110 would suggest 415 only for an unsupported media type and 400 or 422 for invalid content. If you're extending an API like that, partners already depend on its codes: add problem bodies and new types without changing existing status codes, and change them only in a new API version.
AdvancedReading problems as a partner would

The other half of the contract is the client. A good client switches on type, falls back to the status code for anything it doesn't recognise, ignores extensions it doesn't know, never parses detail, and copes with errors that aren't problem details at all:

partner-client.mjs
// How an integration (a room scheduler, a fleet tool) should read errors from the device API.
const TYPES = "https://dev.example.com/sim100/problems";

function decide({ status, contentType, body }) {
  const isProblem = contentType?.startsWith("application/problem+json");
  const p = isProblem ? JSON.parse(body) : { type: "about:blank", status };   // a proxy's HTML page is still an error
  switch (p.type) {                                // the type URI is the identifier; never parse "detail"
    case `${TYPES}/validation-error`:
      return `fix input: ${p.errors.map((e) => e.pointer).join(", ")}`;
    case `${TYPES}/device-busy`:
      return `wait for ${p.activeCall ?? "the current call"} to end, then retry`;
    case `${TYPES}/update-in-progress`:
      return `poll ${p.operation} and retry after it finishes`;
    default:                                       // unknown or generic type: fall back to the status code
      if (status === 503 || status === 429) return "retry later (honour Retry-After)";
      if (status >= 500) return "retry with back-off, then alert";
      return `give up: ${status} ${p.title ?? ""}`.trim();
  }
}

const responses = [
  { status: 422, contentType: "application/problem+json", body: JSON.stringify({ type: `${TYPES}/validation-error`, status: 422, errors: [{ pointer: "#/rate", detail: "must be 512, 1024 or 2048" }] }) },
  { status: 409, contentType: "application/problem+json", body: JSON.stringify({ type: `${TYPES}/device-busy`, status: 409, detail: "Hang up call-7 first.", activeCall: "/api/v1/calls/call-7", hint: "new in 4.3" }) },
  { status: 409, contentType: "application/problem+json", body: JSON.stringify({ type: `${TYPES}/thermal-limit`, title: "Device too hot", status: 409 }) },
  { status: 503, contentType: "application/problem+json", body: JSON.stringify({ type: "about:blank", title: "Service Unavailable", status: 503 }) },
  { status: 502, contentType: "text/html", body: "<html><body>Bad Gateway</body></html>" },
];
const labels = ["known type", "known type + an extension it doesn't know", "type added in a newer firmware", "generic problem", "HTML from a proxy"];
responses.forEach((r, i) => console.log(`${r.status} ${labels[i]}\n  -> ${decide(r)}`));
Result
422 known type
  -> fix input: #/rate
409 known type + an extension it doesn't know
  -> wait for /api/v1/calls/call-7 to end, then retry
409 type added in a newer firmware
  -> give up: 409 Device too hot
503 generic problem
  -> retry later (honour Retry-After)
502 HTML from a proxy
  -> retry with back-off, then alert
What’s happening
  1. decide first checks the content type. Only application/problem+json is parsed as a problem; anything else (here a proxy's HTML 502 page) is treated as { type: "about:blank", status }, so the rest of the code has one shape to deal with.
  2. Known type: validation-error → the client lists the JSON Pointers to fix (#/rate). It uses the errors extension, not the human detail strings, which can be reworded in any release.
  3. Known type plus an unknown extension: the device-busy problem carries hint: "new in 4.3", which this client was never told about. It ignores it, as RFC 9457 requires, and still uses activeCall. This is what lets the device add information without a coordinated release with every partner.
  4. A type added in newer firmware: thermal-limit isn't in the client's switch, so it falls to default and decides by status: 409 is a client-side problem, so "give up" with the title shown to a person. The integration degrades sensibly instead of crashing on an unknown string.
  5. Generic and non-problem responses: about:blank with 503 → "retry later (honour Retry-After)"; the HTML 502 → "retry with back-off, then alert". The status code remains the safety net for everything the type doesn't cover, which is why RFC 9457 insists the status member match the real HTTP status.
AdvancedPartners parsing detail
AdvancedNode's status phrases are older than RFC 9110

#Bulk and transactional configuration

Added

Real configuration changes come in groups. Switching a room from SIP to a Teams or Zoom provider mode changes the provider, its servers, the codecs, the calendar integration and maybe the home screen, all at once; provisioning a new device applies a profile of 200 settings; a technician imports a configuration file exported from another room. Applied one PATCH at a time, a group passes through states nobody designed (provider is SIP but there's no SIP server yet), and if setting 37 of 50 fails, the device is left half-configured with no record of what changed.

So device APIs need a transactional apply: send the whole group, the device validates every value and every rule that spans values against the complete candidate configuration, and then applies all of it or none of it. Two companions make that safe in practice. A dry run (validate-only) answers "what would this change?" without changing anything, so a UI or a fleet tool can show the diff and catch errors before touching a room in use. And per-setting restart-required flags tell the caller which changes take effect live and which only after a reboot, so nobody claims a change worked when it's still pending.

The mental model is a bank transfer, not a series of withdrawals and deposits. You describe the whole change, it's checked as a whole, and the device moves from one valid configuration to another in a single step. Network equipment has worked this way for years: NETCONF (RFC 6241, June 2011) has a candidate configuration that's edited, validated and then committed, and that idea transfers directly to an HTTP device API.

changes many settings each value known? valid? cross rules on full candidate diff live / restart swap all at once revision + 1 dry run stops here 200 + the diff any error 422, nothing changed pendingRestart shown in /status
Validate everything against the whole candidate, then change everything in one step. The dry run is the same pipeline without the last box.
bulk-apply.mjs
import express from "express";
import { once } from "node:events";

// Every setting the device knows: how to check it, and whether it takes effect live or after a restart.
const REGISTRY = {
  "audio.defaultVolume": { valid: (v) => Number.isInteger(v) && v >= 0 && v <= 100, applies: "live" },
  "network.hostname":    { valid: (v) => /^[a-z0-9-]{1,63}$/.test(v), applies: "restart" },
  "provider.mode":       { valid: (v) => ["sip", "teams", "zoom"].includes(v), applies: "restart" },
  "sip.server":          { valid: (v) => typeof v === "string", applies: "live" },
};
// Rules that involve more than one setting, checked on the whole candidate configuration.
const crossChecks = [(c) => (c["provider.mode"] === "sip" && !c["sip.server"] ? { key: "sip.server", detail: "required when provider.mode is sip" } : null)];

let config = { "audio.defaultVolume": 50, "network.hostname": "room-12", "provider.mode": "teams", "sip.server": "" };
let revision = 7;
const pendingRestart = new Set();

function plan(changes) {
  const errors = [];
  for (const [key, value] of Object.entries(changes)) {
    if (!REGISTRY[key]) errors.push({ key, detail: "unknown setting" });
    else if (!REGISTRY[key].valid(value)) errors.push({ key, detail: `invalid value ${JSON.stringify(value)}` });
  }
  const candidate = { ...config, ...changes };
  if (!errors.length) errors.push(...crossChecks.map((check) => check(candidate)).filter(Boolean));
  const diff = Object.entries(changes).filter(([k, v]) => config[k] !== v)
    .map(([key, to]) => ({ key, from: config[key], to, applies: REGISTRY[key].applies }));
  return { errors, candidate, diff, restartRequired: diff.some((d) => d.applies === "restart") };
}

const app = express();
app.use(express.json());
app.post("/api/v1/config/changes", (req, res) => {
  const { errors, candidate, diff, restartRequired } = plan(req.body.changes);
  if (errors.length) return res.status(422).json({ title: "Invalid configuration", errors, applied: false });
  if (req.body.dryRun) return res.json({ dryRun: true, changes: diff, restartRequired });
  config = candidate;                                           // one assignment: all of it or none of it
  revision++;
  diff.filter((d) => d.applies === "restart").forEach((d) => pendingRestart.add(d.key));
  res.json({ revision, changes: diff.map((d) => d.key), restartRequired });
});
app.get("/api/v1/status", (req, res) => res.json({ configRevision: revision, pendingRestart: [...pendingRestart] }));

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}/api/v1`;
const post = async (label, body) => {
  const res = await fetch(`${base}/config/changes`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
  const json = await res.json();
  if (!json.dryRun) return console.log(`${label} -> ${res.status}\n  ${JSON.stringify(json)}`);
  console.log(`${label} -> ${res.status}, restartRequired: ${json.restartRequired}`);
  for (const c of json.changes) console.log(`  ${c.key}: ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)} (${c.applies})`);
};

const changes = { "provider.mode": "sip", "sip.server": "sip.example.com", "audio.defaultVolume": 30, "network.hostname": "room-12b" };
await post("dry run", { dryRun: true, changes });
await post("apply, sip.server left out", { changes: { ...changes, "sip.server": undefined } });
console.log(`status: ${await (await fetch(`${base}/status`)).text()}`);
await post("apply", { changes });
console.log(`status: ${await (await fetch(`${base}/status`)).text()}`);
server.close();
Result
dry run -> 200, restartRequired: true
  provider.mode: "teams" -> "sip" (restart)
  sip.server: "" -> "sip.example.com" (live)
  audio.defaultVolume: 50 -> 30 (live)
  network.hostname: "room-12" -> "room-12b" (restart)
apply, sip.server left out -> 422
  {"title":"Invalid configuration","errors":[{"key":"sip.server","detail":"required when provider.mode is sip"}],"applied":false}
status: {"configRevision":7,"pendingRestart":[]}
apply -> 200
  {"revision":8,"changes":["provider.mode","sip.server","audio.defaultVolume","network.hostname"],"restartRequired":true}
status: {"configRevision":8,"pendingRestart":["provider.mode","network.hostname"]}
What’s happening
  1. REGISTRY is the device's list of every setting it knows, with a validator and whether it applies live or after a restart. It's the single source of truth: the same registry can generate the API's schema and the web UI's form (see Forms generated from a settings schema). crossChecks holds rules that involve several settings, like "SIP mode needs a SIP server".
  2. Dry run: plan() checks each key (unknown keys and bad values are errors), builds the candidate { ...config, ...changes }, runs the cross checks on it, and computes the diff against the current config. The request had dryRun: true, so the handler returns 200 with the four changes, each with its from, to and applies, and restartRequired: true, without touching config. A UI can show exactly this as "Review changes".
  3. Apply with sip.server left out: JSON.stringify drops the undefined member, so the request only has three changes. Each value is valid on its own, but the candidate has provider.mode: "sip" and sip.server: "", so the cross check fails and the device returns 422 with applied: false. The status line confirms nothing moved: still configRevision: 7, nothing pending. Applied one by one, the provider switch would already have happened.
  4. Apply with all four changes passes, and config = candidate replaces the configuration in a single assignment: there's no moment when some of the four are applied and others aren't. The revision goes 7 → 8, the response lists what changed and says restartRequired: true.
  5. The final status shows pendingRestart: ["provider.mode", "network.hostname"]: those two are stored but not yet in effect. The UI can show a "Restart to finish applying" banner and the fleet tool can schedule the reboot for the night. The live changes (sip.server, audio.defaultVolume) took effect at once.
AdvancedDesigning the bulk endpoint
  • Shape. The model uses POST /config/changes with flat dotted keys, which makes per-setting results and restart flags easy to report. A merge patch on /settings with ?dryRun=true (or a Prefer header) works too; what matters is that one request is one transaction. Kubernetes, for comparison, exposes server-side dry run as ?dryRun=All on its normal write requests: "every stage runs as normal, except for the final storage stage", and the response has the normal status code (Kubernetes "API Concepts" documentation, checked October 2026).
  • Dry run must run the same code as the real apply, minus the final swap. A separate "validate" implementation drifts and starts accepting things the apply rejects.
  • Return a diff, not just "valid". Admins and reviewers want to see provider.mode: teams → sip (restart) before they commit; fleet tools use the diff to skip devices already in the target state.
  • Restart semantics per setting. Mark each setting live, restart (applies after a reboot) or service-restart (the device restarts one subsystem, dropping calls for a few seconds). Report them in the dry run and expose pending ones in /status. Don't reboot automatically in the middle of a meeting; let the caller decide or schedule it.
  • Combine with If-Match. Send the configuration revision's ETag with the apply, so a bulk change computed from yesterday's export fails with 412 instead of undoing today's edits.
  • Many devices is a different problem. Applying a profile to 500 rooms happens in the cloud management service, which calls each device's transactional apply and tracks per-device results; that fleet-level job is a long-running operation with partial success. On the device, keep it all-or-nothing. If an API does return per-item results for a batch, 200 with a result per item is clearer to most clients than 207 Multi-Status, which comes from WebDAV (RFC 4918).
AdvancedConfirm or roll back risky changes

Changes that can cut you off: confirm or roll back

Some changes break the connection that made them: a new IP address, a VLAN, a firewall rule, a TLS certificate the admin's browser doesn't trust, a disabled HTTP port. If the change is wrong, the admin can't reach the device to undo it, and someone has to walk to the room. NETCONF's confirmed commit solves this: per RFC 6241 section 8.4, a confirmed commit "MUST be reverted if a confirming commit is not issued within the timeout period (by default 600 seconds = 10 minutes)", and also if the device reboots before it's confirmed. The same pattern works over HTTP:

confirmed-apply.mjs
// "Apply, then confirm or roll back": the idea behind NETCONF's confirmed commit (RFC 6241, 8.4),
// for changes that can cut the admin off (IP address, VLAN, TLS certificate). Fake clock in seconds.
let now = 0;
let config = { "network.ipv4": "10.0.4.20", "network.vlan": 10 };
let pending = null;   // { previous, deadline }

function applyConfirmed(changes, timeoutSeconds) {
  pending = { previous: config, deadline: now + timeoutSeconds };   // keep the last known-good config
  config = { ...config, ...changes };
  return `applied; confirm within ${timeoutSeconds}s from the new address or it rolls back`;
}
function confirm() {
  if (!pending) return "nothing to confirm";
  pending = null;
  return "confirmed: the new config is now the known-good one";
}
function tick(seconds) {
  now += seconds;
  if (pending && now >= pending.deadline) {
    config = pending.previous;                                      // the admin never came back: undo
    pending = null;
    console.log(`  t=${now}s  no confirmation -> rolled back`);
  }
}
const reachable = (fromNetwork) => config["network.vlan"] === fromNetwork;

console.log("Admin on VLAN 10 moves the device to VLAN 30 by mistake:");
console.log(`  t=${now}s  ${applyConfirmed({ "network.vlan": 30, "network.ipv4": "10.0.30.20" }, 60)}`);
console.log(`  t=${now}s  admin can reach the device: ${reachable(10)}`);
tick(30); tick(30);
console.log(`  t=${now}s  admin can reach the device: ${reachable(10)}  config: ${JSON.stringify(config)}`);

console.log("Planned move, admin reconnects on VLAN 30 and confirms:");
console.log(`  t=${now}s  ${applyConfirmed({ "network.vlan": 30, "network.ipv4": "10.0.30.20" }, 60)}`);
tick(20);
console.log(`  t=${now}s  reachable from VLAN 30: ${reachable(30)} -> ${confirm()}`);
tick(60);
console.log(`  t=${now}s  config: ${JSON.stringify(config)}`);
Result
Admin on VLAN 10 moves the device to VLAN 30 by mistake:
  t=0s  applied; confirm within 60s from the new address or it rolls back
  t=0s  admin can reach the device: false
  t=60s  no confirmation -> rolled back
  t=60s  admin can reach the device: true  config: {"network.ipv4":"10.0.4.20","network.vlan":10}
Planned move, admin reconnects on VLAN 30 and confirms:
  t=60s  applied; confirm within 60s from the new address or it rolls back
  t=80s  reachable from VLAN 30: true -> confirmed: the new config is now the known-good one
  t=140s  config: {"network.ipv4":"10.0.30.20","network.vlan":30}
What’s happening
  1. applyConfirmed saves the current configuration as previous (the last known-good one), applies the change, and sets a deadline 60 fake seconds away. In an HTTP API this would be POST /config/changes with "confirmTimeoutSeconds": 60, returning an id to confirm.
  2. The mistake: the admin, on VLAN 10, moves the device to VLAN 30. Immediately reachable(10) is false: the admin's browser can no longer talk to the device, so they can't send the confirmation, and they can't undo the change either.
  3. Two tick(30) calls bring the clock to 60 s. The deadline has passed with no confirmation, so the device restores previous by itself: config is back to 10.0.4.20 on VLAN 10 and the admin can reach the device again. Nobody had to visit the room.
  4. The planned move: the same change, but this time the admin reconnects from VLAN 30 at 80 s, can reach the device at its new address, and calls confirm(). pending is cleared, so the new configuration becomes the known-good one.
  5. At 140 s, past the old deadline, the config is still the VLAN 30 one: a confirmed change is never rolled back. The rule for the API: changes that can affect reachability (network, ports, certificates, auth) should require or at least offer confirmation, the timeout should survive a reboot (NETCONF also reverts if the device reboots first), and the web UI should handle the confirm step for the user, ideally by reconnecting to the new address automatically.
AdvancedRestart-required settings reported as applied

#Designing for constrained devices

Added

The API server on a device shares a modest CPU and memory with the things the device is actually for: encoding and decoding video, echo cancellation, camera tracking, the call stack. Every millisecond spent serialising JSON for a dashboard that polls every second is a millisecond the media pipeline doesn't get, and nobody wants a meeting to stutter because six browser tabs have the admin page open. A cloud API scales out when traffic grows; a device can't. The API has to be cheap by design: small responses, few connections, bounded work per request, and clear signals telling clients to slow down.

The mental model is a small shop with one assistant. The assistant can serve a few customers well. Things go wrong when everyone asks for the full catalogue every minute, when one customer holds the door open for twenty minutes, or when a coach party arrives at once. The fixes are the same as in a shop: answer "nothing has changed" cheaply, give people only the page they asked for, limit how many come in at once, and tell the rest when to come back.

The biggest single win is usually conditional polling. A web UI that refreshes settings every 10 seconds receives the same document almost every time. With an ETag and If-None-Match, the device answers 304 Not Modified with no body when nothing changed. This model counts bytes and work for 30 polls, with one change half-way through:

conditional-polling.mjs
import express from "express";
import { once } from "node:events";

// A web UI tab polls the settings every 10 s. The device has a slow CPU and many open tabs to serve.
let rev = 1;
const settings = {
  audio: { defaultVolume: 50, muteOnJoin: false, echoCancellation: true, noiseSuppression: "high" },
  display: { brightness: 80, sleepAfterMinutes: 10, layout: "speaker" },
  network: { hostname: "room-12", ntpServers: ["ntp1.example.com", "ntp2.example.com"], dhcp: true },
  provider: { mode: "teams", tenantHint: "example.onmicrosoft.com" },
};
let serialised = 0;
const render = (req) => {
  serialised++;
  const doc = req.query.fields ? Object.fromEntries(req.query.fields.split(",").map((f) => [f, settings[f]])) : settings;
  return JSON.stringify(doc);
};

const app = express();
app.get("/api/v1/settings", (req, res) => {
  res.set("ETag", `"r${rev}"`).set("Cache-Control", "no-cache");   // caches must revalidate every time
  res.type("json").send(render(req));                             // Express turns this into a 304 when fresh
});
app.get("/api/v1/settings-cheap", (req, res) => {
  res.set("ETag", `"r${rev}"`).set("Cache-Control", "no-cache");
  if (req.fresh) return res.status(304).end();                    // decide before doing any work
  res.type("json").send(render(req));
});
const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}/api/v1`;

async function pollFor(label, path, { conditional, init = {} }) {
  let etag = null, bytes = 0;
  const codes = {};
  serialised = 0;
  for (let i = 1; i <= 30; i++) {
    if (i === 15) { settings.audio.defaultVolume = 40; rev++; }    // someone changes a setting halfway
    const headers = conditional && etag ? { "if-none-match": etag } : {};
    const res = await fetch(base + path, { ...init, headers });
    bytes += (await res.arrayBuffer()).byteLength;
    codes[res.status] = (codes[res.status] ?? 0) + 1;
    if (res.status === 200) etag = res.headers.get("etag");
  }
  console.log(`${label}\n  ${JSON.stringify(codes)}  ${bytes} body bytes  ${serialised} documents serialised`);
}

await pollFor("plain GET", "/settings", { conditional: false });
await pollFor("If-None-Match, fetch's default cache mode", "/settings", { conditional: true });
await pollFor('If-None-Match, cache: "no-cache"', "/settings", { conditional: true, init: { cache: "no-cache" } });
await pollFor('same, ?fields=audio', "/settings?fields=audio", { conditional: true, init: { cache: "no-cache" } });
await pollFor('same, req.fresh checked first', "/settings-cheap?fields=audio", { conditional: true, init: { cache: "no-cache" } });
server.close();
Result
plain GET
  {"200":30}  10020 body bytes  30 documents serialised
If-None-Match, fetch's default cache mode
  {"200":30}  10020 body bytes  30 documents serialised
If-None-Match, cache: "no-cache"
  {"200":2,"304":28}  668 body bytes  30 documents serialised
same, ?fields=audio
  {"200":2,"304":28}  198 body bytes  30 documents serialised
same, req.fresh checked first
  {"200":2,"304":28}  198 body bytes  2 documents serialised
What’s happening
  1. The /settings route sets a strong ETag ("r1", then "r2" after the change at poll 15) and Cache-Control: no-cache, which per RFC 9111 means a cache may store the response but "MUST NOT" reuse it without revalidating, which is exactly right for settings that can change at any time. It then sends the rendered document with res.send, and Express compares If-None-Match with the ETag and turns the response into a 304 with no body when they match.
  2. Plain GET: 30 responses of 334 bytes each, 10020 body bytes, and 30 documents serialised. That's the baseline for one tab; multiply by tabs and by the number of status-like resources a dashboard polls.
  3. If-None-Match with fetch's default cache mode: the client sends the tag it got, yet every response is still a full 200. The reason is in the Fetch standard, which Node's fetch implements: when a request carries If-None-Match (or another conditional header) and the cache mode is "default", fetch switches to "no-store" and adds Cache-Control: no-cache and Pragma: no-cache to the request. Express's freshness check treats a request no-cache as "send the full response", so the 304 never happens. These are steps of the Fetch Standard's "HTTP-network-or-cache fetch" algorithm, and the undici 8.9.0 bundled with Node 26.7 implements them in lib/web/fetch/index.js. (Observed on Node 26.7: the server logged cache-control: no-cache and pragma: no-cache on the default-mode request, and cache-control: max-age=0 with no Pragma on the "no-cache" one.) Browsers follow the same standard (not tested in a browser here).
  4. With cache: "no-cache": fetch sends Cache-Control: max-age=0 instead, which doesn't defeat the check: 2 responses are 200 (the first poll and the one after the change) and 28 are 304, and body bytes drop from 10020 to 668. The client must still update its stored tag only on 200, as pollFor does.
  5. ?fields=audio returns only the section the page shows, so the two full responses are smaller: 198 bytes. But the server column still says 30 documents serialised: Express builds the whole body before it discovers the request is fresh. The last line uses a route that checks req.fresh right after setting the ETag and returns 304 before calling render, so only 2 documents are serialised. On a device, that's the version you want: decide "not modified" before doing any work.
AdvancedPayload size and shape
  • Return sections, not the world. /settings/audio instead of /settings, ?fields= for clients that need a few members, summaries in lists (/peripherals returns id, kind and connected; /peripherals/{id} has the details). Big documents cost the device CPU to serialise and memory to buffer.
  • Paginate anything that grows: logs, call history, directory entries. Cursor-based paging with a server-enforced maximum page size keeps every request bounded. (The System Design course covers cursors in Pagination at the API level.)
  • Stream large downloads (log bundles, configuration exports, screenshots) with res.sendFile or a stream piped to the response, never by building a buffer. The Poly VideoOS guide, for example, returns its logs from GET /rest/system/logs as a .tar octet-stream attachment.
  • Compression is a trade-off. gzip cuts JSON by a large factor but costs CPU; on a busy device it's often better to compress large, cacheable downloads (or store them pre-compressed) and leave small API responses uncompressed. Measure on the real hardware.
  • Cap request bodies. Express's JSON parser defaults to 100kb; a device should set its own limits per route (a settings patch needs a few kilobytes; a firmware upload needs a separate streaming endpoint with a limit in hundreds of megabytes), and answer 413 Content Too Large. Poly's VideoOS guide documents a 10 MB cap for its REST API.
AdvancedRate limits, body caps and connections

Connections, rate limits and Retry-After

Two cheap protections cover most abuse, accidental or not: a cap on body size and a per-client rate limit. Order matters: reject by rate before parsing the body, so a flood of large requests costs almost nothing.

device-limits.mjs
import express from "express";
import { once } from "node:events";

// Two protections a small device needs: a body-size cap and a per-client rate limit.
const clock = { now: 0 };                                           // fake time in seconds
const bucket = { capacity: 5, tokens: 5, refillPerSecond: 1, last: 0 };   // 5-request burst, 1 per second after

function rateLimit(req, res, next) {
  bucket.tokens = Math.min(bucket.capacity, bucket.tokens + (clock.now - bucket.last) * bucket.refillPerSecond);
  bucket.last = clock.now;
  const remaining = Math.floor(bucket.tokens);
  if (remaining < 1) {
    const wait = Math.ceil((1 - bucket.tokens) / bucket.refillPerSecond);
    res.set("Retry-After", String(wait)).set("RateLimit", `"default";r=0;t=${wait}`);
    return res.status(429).type("application/problem+json").json({
      type: "https://iana.org/assignments/http-problem-types#quota-exceeded", title: "Too Many Requests", status: 429, "violated-policies": ["default"] });
  }
  bucket.tokens -= 1;
  res.set("RateLimit", `"default";r=${remaining - 1}`);   // header syntax from an IETF Internet-Draft (not yet an RFC)
  next();
}

const app = express();
app.use(rateLimit);
app.use(express.json({ limit: "8kb" }));                             // Express's default is 100kb
app.post("/api/v1/directory/entries", (req, res) => res.status(201).json({ id: "entry-1" }));
app.get("/api/v1/status", (req, res) => res.json({ ok: true }));
app.use((err, req, res, next) =>
  res.status(err.status ?? 500).type("application/problem+json").json({ title: err.status === 413 ? "Content Too Large" : "Error", status: err.status ?? 500, limit: err.limit }));

const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}/api/v1`;
const show = async (label, path, init) => {
  const res = await fetch(base + path, init);
  const h = ["ratelimit", "retry-after"].filter((n) => res.headers.has(n)).map((n) => `${n}: ${res.headers.get(n)}`).join("  ");
  console.log(`t=${clock.now}s ${label.padEnd(22)} ${res.status}  ${h}${res.status >= 400 ? `\n      ${await res.text()}` : ""}`);
};
const post = (bytes) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "x".repeat(bytes) }) });

await show("POST 1 KB entry", "/directory/entries", post(1024));
await show("POST 20 KB entry", "/directory/entries", post(20 * 1024));
for (let i = 1; i <= 4; i++) await show(`GET status #${i}`, "/status");
clock.now = 2;
await show("GET status, 2 s later", "/status");
await show("GET status", "/status");
await show("GET status", "/status");
server.close();
Result
t=0s POST 1 KB entry        201  ratelimit: "default";r=4
t=0s POST 20 KB entry       413  ratelimit: "default";r=3
      {"title":"Content Too Large","status":413,"limit":8192}
t=0s GET status #1          200  ratelimit: "default";r=2
t=0s GET status #2          200  ratelimit: "default";r=1
t=0s GET status #3          200  ratelimit: "default";r=0
t=0s GET status #4          429  ratelimit: "default";r=0;t=1  retry-after: 1
      {"type":"https://iana.org/assignments/http-problem-types#quota-exceeded","title":"Too Many Requests","status":429,"violated-policies":["default"]}
t=2s GET status, 2 s later  200  ratelimit: "default";r=1
t=2s GET status             200  ratelimit: "default";r=0
t=2s GET status             429  ratelimit: "default";r=0;t=1  retry-after: 1
      {"type":"https://iana.org/assignments/http-problem-types#quota-exceeded","title":"Too Many Requests","status":429,"violated-policies":["default"]}
What’s happening
  1. rateLimit is a token bucket: 5 tokens, refilled at 1 per second of the fake clock. Each request refills the bucket for the time that passed, then takes a token or gets 429. (The algorithms and their trade-offs are covered in the System Design course's Rate limiting algorithms; on a device one process holds the bucket in memory, so there's no shared store to worry about.)
  2. The 1 KB directory entry is accepted (201) and the response says RateLimit: "default";r=4: four requests left. The RateLimit header syntax comes from the Internet-Draft draft-ietf-httpapi-ratelimit-headers-11 (23 May 2026), which isn't an RFC yet; r is the remaining quota and t the effective window in seconds. Clients that understand it can slow down before they hit the limit.
  3. The 20 KB entry is over the route's 8kb limit. Express's body parser rejects it with 413 before any handler runs, and the error middleware returns a problem body with the limit (8192 bytes). It still used a token, because the rate limiter runs first: that's deliberate, so oversized floods are throttled too.
  4. Three GET /status calls use up the bucket (r=2, r=1, r=0) and the fourth gets 429 with Retry-After: 1 and RateLimit: "default";r=0;t=1. The body uses the problem type the draft defines, …http-problem-types#quota-exceeded (not yet registered with IANA), with its violated-policies extension, so clients can recognise it without guessing.
  5. Two fake seconds later the bucket has refilled 2 tokens: two requests succeed (r=1, r=0) and the third is limited again. The client that honours Retry-After gets served steadily; the one that hammers just collects 429s, which are cheap for the device to produce.

The other resource a device runs out of is connections. Each open socket costs memory, and a few slow downloads or forgotten keep-alive connections can block the admin who needs to fix something. Node's server.maxConnections refuses connections over a limit:

connection-cap.mjs
import express from "express";
import { once } from "node:events";

// A small device caps concurrent connections so a busy dashboard can't starve the call stack.
const app = express();
let release, waiting = 0, dropped = 0, allArrived;
const gate = new Promise((r) => (release = r));
const arrived = new Promise((r) => (allArrived = r));
const check = () => waiting === 2 && dropped === 1 && allArrived();
app.get("/api/v1/logs/bundle", async (req, res) => {
  waiting++;
  check();
  await gate;                                   // a slow download holds its connection open
  res.json({ ok: true });
});
const server = app.listen(0, "127.0.0.1");
server.maxConnections = 2;
server.on("drop", () => (dropped++, check()));  // Node emits "drop" when it refuses a connection over the cap
await once(server, "listening");
const url = `http://127.0.0.1:${server.address().port}/api/v1/logs/bundle`;

const results = [1, 2, 3].map(() => fetch(url).then((r) => String(r.status), (e) => e.cause?.code ?? e.message));
await arrived;                                  // two requests in progress, one connection refused
release();
const outcomes = (await Promise.all(results)).sort();  // which client loses the race isn't fixed, the counts are
console.log(`three clients: ${outcomes.join(", ")}`);
console.log(`connections dropped by the server: ${dropped}`);
server.close();
Result
three clients: 200, 200, ECONNRESET
connections dropped by the server: 1
What’s happening
  1. The handler for /logs/bundle waits on gate, standing in for a slow download that holds its connection open. server.maxConnections = 2 tells Node to refuse any connection beyond two open ones, and the server's "drop" event (Node 18.6 and later) fires each time it does.
  2. Three clients request a bundle at once. Node's fetch opens a connection per concurrent request; two are accepted and their requests reach the handler (waiting goes 1 → 2), and the third connection is closed by the server as soon as it's accepted, so dropped becomes 1. check() waits for exactly that state, so the model doesn't depend on timing.
  3. release() lets the two slow downloads finish with 200. The third client's fetch rejected with ECONNRESET: it got a reset connection, not an HTTP response. The outcomes are sorted before printing because which client loses the race isn't guaranteed; that two succeed and one is refused is.
  4. That's the weakness of a raw connection cap: refused clients get a network error, not a helpful 503 with Retry-After, and a bare cap can lock out the admin along with the dashboard. Real devices combine a generous cap with smarter limits higher up: a limit on concurrent expensive operations (one log bundle at a time, answered with 409 or 503 + Retry-After), per-client rate limits as above, and short idle timeouts.

Timeouts are the other half of connection hygiene. Node 26's HTTP server defaults, printed from http.createServer() while writing this, are headersTimeout 60 000 ms, requestTimeout 300 000 ms (5 minutes for the whole request), keepAliveTimeout 5 000 ms, a 16 KB header limit and no connection cap. A device usually wants a shorter requestTimeout for ordinary API routes (a slow client uploading a settings patch at one byte per second shouldn't hold a socket for 5 minutes) and a dedicated, longer one only for upload endpoints. Long-lived event streams are a connection budget of their own; Constrained devices and many clients covers fan-out and backpressure.

AdvancedCaching headers by resource
ResourceHeadersWhy
/device (identity)ETag, Cache-Control: max-age=300changes only on update or reboot; short caching is safe
/settings, /settings/*strong ETag, Cache-Control: no-cachecan change any time; always revalidate, 304 makes it cheap
/statusETag, Cache-Control: no-cache (or no-store)changes constantly; better pushed as events than polled (Snapshot plus events)
/operations/{id} (running)Retry-After, no-storetells pollers how often; final states can be cached
/logs/{id} (bundle)Content-Disposition: attachment, ETaglarge, immutable once made: stream it
Web UI static assetsCache-Control: max-age=31536000, immutable with hashed file namesthe admin UI shouldn't be re-downloaded from the device on every visit
Advancedfetch quietly disables your 304s
How much does the web UI's polling cost?

An admin dashboard polls /status (2 KB) and /settings (8 KB) every 5 seconds. Five people leave it open all day. Status changes most polls; settings change twice a day. Roughly how much response body does the device send per hour, and what are the two biggest fixes?

Show answer

About 36 000 KB (roughly 36 MB) of response bodies per hour, 80% of it settings that almost never change. Conditional requests for settings remove nearly all of that, and pushing status as events instead of polling removes most of the rest.

What’s happening
  1. Each tab polls 3600 / 5 = 720 times per hour, so five tabs make 3600 polls of each resource per hour, 7200 requests in all.
  2. Settings: 3600 × 8 KB = 28 800 KB, for data that changes twice a day. Status: 3600 × 2 KB = 7 200 KB. Together 36 000 KB per hour of bodies alone (headers and the requests come on top), and every one of the 7200 responses also costs a serialisation on the device.
  3. Fix 1: strong ETag + If-None-Match on settings, with freshness checked before rendering. Nearly every settings poll becomes a 304 with no body, so the 28 800 KB falls to a few hundred bytes of headers per poll, and the device does almost no work for them.
  4. Fix 2: replace status polling with one event stream per tab (Server-Sent Events or a WebSocket, chapter 7) that sends only the fields that changed. Status changes still cost bytes, but only when something changed, and the device serialises one event per change instead of one document per poll per tab.
  5. Then lengthen the interval for anything that isn't on screen (a hidden tab can poll once a minute or stop), which removes most of what's left for every tab nobody is looking at.

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.