#Modelling a device as resources
AddedA 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.
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:
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();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}- Four plain objects hold the device:
device(identity),settings(what an admin chose),status(what the device reports) and twoMaps for the collections. They're separate on purpose:status.callStatechanges when a call starts, and that must not look like anyone edited the settings. GET /deviceandGET /peripherals/mic-1are simple reads. The microphone shows"connected": false: that's reported state about a peripheral, and nobody canPATCHit totrue. Asking forhdmi-9, which doesn't exist, gives404, the same as any missing member of a collection.POST /callswith a dial string creates a call. It returns201 Created, the new call as the body andLocation: /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.- The next
GET /statusshows"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. DELETE /calls/call-1hangs up and returns204 No Content. The secondDELETEreturns404because the call is already gone. The state on the device is the same after one hang-up or two ("no call"), which is what makesDELETEidempotent; a client that retried a hang-up should treat that404as "already done", not as a failure.- The last
GET /statusis back to"idle". Six URLs and three methods describe the whole interaction, and every caller (web UI, cloud, partner) uses the same ones.
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:
| Collection | Who creates members | Methods clients use | Notes |
|---|---|---|---|
/peripherals | the device, when it detects hardware | GET | ids must be stable across reboots: use the serial number or port, not "the second USB device found" |
/calls | clients (dialling) and the device (incoming calls) | GET, POST, DELETE | DELETE hangs up; call state lives on the call resource |
/logs | the device | GET (list), GET /logs/{id} (download) | bundles are large: stream them, page the list |
/operations | the server, in response to POST on an action | GET, cancel | see Long-running operations |
/directory/entries | clients | full CRUD | an 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.
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 command | What 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 role | login is a resource you create; every other command needs the session |
GET /rest/system/status | status of important areas of the system (network, SIP server, gatekeeper…) | reported state in its own resource |
GET / POST /rest/audio/volume | read the level / set it; the body is a bare integer | read and write on the same noun |
POST /rest/conferences, DELETE /rest/conferences/{confID} | start a conference / end it | calls as a collection |
POST /rest/system/reboot with { "action": "reboot" } | reboots the system | an action as POST to a noun, with the verb in the body |
GET /rest/system/logs | the logs as a .tar file, an octet-stream attachment | large 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).
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}).
- 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.
- 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.
- Writable runtime state is fine as long as it's its own resource.
PUT /api/v1/audio/mutewithtrueis idempotent ("the microphones are muted"), so a retried request can't toggle it back, unlike aPOST /audio/toggle-mute. - 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, hasGET/POST /rest/audio/mutedwith a bare boolean body, separate from configuration.)