REST and WebSocket API¶
Base URL: http://10.99.0.1/api/v1. Every example below assumes the host has the USB
network link up (see host setup); swap curl
for whatever HTTP client your language provides — there is nothing device-specific about
the transport, it's plain HTTP/1.1 and a standard WebSocket.
HTTPS is also available once a server certificate is uploaded
(PUT /files/certificates/server, see Files below);
enabling it never disables plain HTTP.
Full schemas
This page shows the shape of every request/response with runnable examples. The
exhaustive, machine-readable contract (every field, every enum) is
api/openapi.yaml in the repository, also served by the device itself at
GET /api/v1/openapi.yaml. The serial equivalent of every call
below is a one-to-one frame mapping.
Device — identity, health, lifecycle¶
| Method & path | Effect |
|---|---|
GET /device |
Identity & capabilities |
GET /device/health |
Liveness & metrics |
GET /device/logs?since=&limit= |
Structured agent logs |
POST /device/reboot |
Clean reboot |
POST /device/shutdown |
Clean poweroff |
POST /device/factory-reset |
Wipe data partition to defaults and reboot |
GET /network |
Hostname + network interfaces |
{
"serial": "MCD-0001A2B3",
"name": "front-desk-1",
"model": "Raspberry Pi 4 Model B Rev 1.4",
"model_profile": "pi4",
"version": "1.0.0",
"slot": "a",
"api": "v1",
"capabilities": { "h264_hw": true, "hevc_hw": true, "touch_present": false, "wifi": true, "bluetooth": true }
}
curl -X POST http://10.99.0.1/api/v1/device/reboot
curl -X POST http://10.99.0.1/api/v1/device/shutdown
Factory reset requires the device serial as a confirmation token, to prevent accidental wipes:
curl -X POST http://10.99.0.1/api/v1/device/factory-reset \
-H 'content-type: application/json' \
-d '{"confirm": "MCD-0001A2B3"}'
A mismatched confirm returns 400 confirm_mismatch.
{ "hostname": "front-desk-1", "interfaces": [ { "name": "usb0", "addresses": ["10.99.0.1", "fe80::1"] } ] }
The same shape is pushed live as the network_changed event — see Events.
Display — what is on screen¶
| Method & path | Effect |
|---|---|
GET /display |
Current state |
GET /display/screens |
Enumerate display outputs |
POST /display/navigate |
Navigate to a remote URL |
POST /display/bundle |
Display an uploaded content bundle |
POST /display/waiting |
Return to the waiting page |
POST /display/reload |
Reload the current page |
POST /display/restart-browser |
Restart the browser backend |
POST /display/screen |
Display power on/off (HDMI-CEC) |
GET /display/screenshot |
PNG of the current frame |
POST /display/execute-js |
Evaluate JavaScript in the current page |
All display/* calls accept an optional ?screen=<id> query parameter (see
GET /display/screens). v1 renders a single screen, so omit it — it is reserved for
future multi-output boards; any value other than the primary screen's id returns
400 screen_not_found.
{ "mode": "url", "url": "https://pos.example.com/recap", "screen_on": true, "rotation": 0, "resolution": "1920x1080@60" }
curl -X POST http://10.99.0.1/api/v1/display/navigate \
-H 'content-type: application/json' \
-d '{"url": "https://pos.example.com/recap"}'
curl -X POST http://10.99.0.1/api/v1/display/bundle \
-H 'content-type: application/json' \
-d '{"name": "promo"}'
curl -X POST http://10.99.0.1/api/v1/display/waiting
curl -X POST http://10.99.0.1/api/v1/display/reload
curl -X POST http://10.99.0.1/api/v1/display/restart-browser
curl -X POST http://10.99.0.1/api/v1/display/screen \
-H 'content-type: application/json' \
-d '{"on": false}'
Display power is toggled over HDMI-CEC and is best-effort — it requires a CEC-capable display; the browser stays alive and the CRTC stays active either way.
curl -X POST http://10.99.0.1/api/v1/display/execute-js \
-H 'content-type: application/json' \
-d '{"script": "document.title"}'
execute-js is the blunt tool for one-off inspection/automation; for application
messaging with the displayed page, prefer the page channel.
Page channel — messaging with the displayed page¶
The in-page counterpart is display-client.js, injected into every page the device
renders. Messages from the page arrive as page_message events (see
Events); the host side sends messages the other way with:
| Method & path | Effect |
|---|---|
POST /page/message |
Send a JSON message to the page |
GET /page/status |
Whether the current page has opened the channel |
curl -X POST http://10.99.0.1/api/v1/page/message \
-H 'content-type: application/json' \
-d '{"cart_total": 42.50, "currency": "EUR"}'
409 page_channel_closed if the currently displayed page never opened the channel (a
plain remote page that doesn't load display-client.js, for instance).
Content — local web bundles¶
| Method & path | Effect |
|---|---|
GET /content |
List bundles + data-partition disk space |
PUT /content/{name} |
Upload a bundle (zip, index.html at archive root) |
POST /content/{name}/check |
Re-validate a stored bundle archive |
DELETE /content/{name} |
Remove a bundle |
A content bundle is a zip archive — the only accepted format — with index.html at
its root. It is stored and served as-is, never extracted. See the content bundles guide
for authoring details.
{
"bundles": [ { "name": "promo", "size_bytes": 184320, "sha256": "…", "created": "2026-06-01T10:00:00Z", "active": true } ],
"disk": { "free_bytes": 1073741824, "total_bytes": 2147483648 }
}
curl -X PUT http://10.99.0.1/api/v1/content/promo \
-H 'content-type: application/zip' \
--data-binary @promo.zip
Upload is a long-running operation: the response is 202 with an Operation
resource (kind: content_upload) you poll at GET /operations/{id} or watch via the
operation event. Any validation failure (not a zip, CRC mismatch, unsafe/absolute/..
entry paths, symlink entries, missing root index.html) is 400 bundle_invalid and
leaves any existing bundle of that name untouched.
check is synchronous and returns 200 even when the archive is corrupt — corruption
is data, not a transport error (ok: false plus a human-readable reason). 404
content_not_found only if no bundle has that name.
409 bundle_in_use if the bundle is currently displayed — switch to another target
first (POST /display/waiting or navigate elsewhere).
Configuration¶
| Method & path | Effect |
|---|---|
GET /config |
Full current configuration |
PATCH /config |
Partial configuration update |
See the configuration reference for every field.
curl -X PATCH http://10.99.0.1/api/v1/config \
-H 'content-type: application/json' \
-d '{"display": {"rotation": 90}, "audio": {"volume": 60}}'
{
"config": { "...": "full resulting config" },
"applied": { "display.rotation": "live", "audio.volume": "live" }
}
PATCH accepts any subset of the tree; unknown fields are rejected 400 bad_request
(no partial write). Every field present in the patch is reported in applied as either
live (took effect immediately) or reboot_required (persisted now, effective at next
boot/restart). A successful PATCH also emits a config_changed event so other
subscribed hosts and in-page consumers stay in sync without polling.
Files — splash, certificates¶
| Method & path | Effect |
|---|---|
GET/PUT/DELETE /files/splash |
Boot/waiting-page branding image |
GET/PUT/DELETE /files/splash/shutdown |
Shutdown image |
PUT/DELETE /files/certificates/ca |
Trusted CA chain (PEM) |
PUT/DELETE /files/certificates/server |
API HTTPS cert + key (PEM) |
curl -X PUT http://10.99.0.1/api/v1/files/splash \
-H 'content-type: image/png' \
--data-binary @logo.png
PNG only, 4 MB max, validated from the actual file content (not the filename); a
mismatch is 400 bad_request, oversize is 413.
curl http://10.99.0.1/api/v1/files/splash -o splash.png
curl -X DELETE http://10.99.0.1/api/v1/files/splash
files/splash/shutdown follows the same shape (falls back to the boot image, then the
built-in default, when absent).
curl -X PUT http://10.99.0.1/api/v1/files/certificates/server \
-H 'content-type: application/x-pem-file' \
--data-binary @server-cert-and-key.pem
Audio¶
| Method & path | Effect |
|---|---|
GET /audio |
Current output, volume, mute |
POST /audio |
Set volume/mute |
curl http://10.99.0.1/api/v1/audio
curl -X POST http://10.99.0.1/api/v1/audio \
-H 'content-type: application/json' \
-d '{"volume": 80, "mute": false}'
Update — OTA¶
| Method & path | Effect |
|---|---|
GET /update |
Slot status |
PUT /update/bundle |
Stream a signed update bundle to the inactive slot |
POST /update/apply |
Boot into the updated slot |
{ "active_slot": "a", "slots": { "a": { "version": "1.0.0", "state": "good" }, "b": { "version": "0.9.0", "state": "unknown" } } }
curl -X PUT http://10.99.0.1/api/v1/update/bundle \
-H 'content-type: application/octet-stream' \
--data-binary @mcd-1.1.0.raucb
202 with an Operation (kind: update_install) — verification + install to the
inactive slot. Once it reports done, boot into it:
The device reboots into the new slot; if it fails to boot successfully the bootloader rolls back to the previous slot automatically. Upload and apply are deliberately two calls so the host chooses the reboot moment.
Operations — long-running task status¶
404 operation_not_found for an unknown/expired id.
Input — detected devices¶
Raw touch/pointer events themselves are not available over REST — subscribe to the
input_raw WebSocket/serial topic instead (see Events).
NFC — reader & tags (optional, spec 10)¶
Every /nfc/* endpoint is gated by config.nfc.enabled: while it is false (the
default), all of them return 503 nfc_disabled and no nfc_* event is emitted — check
GET /config (nfc.enabled) before assuming the surface is live. Turn it on via
configuration.
| Method & path | Effect |
|---|---|
GET /nfc/readers |
List present readers |
POST /nfc/transceive |
Raw ISO-DEP APDU exchange with the present tag |
POST /nfc/ndef/write |
Write an NDEF message to the present tag |
POST /nfc/reader/feedback |
Drive the reader's LED/buzzer |
[ { "id": "pn532-i2c-0", "backend": "pn532-i2c", "present": true, "capabilities": { "techs": ["iso-dep", "ndef"], "has_led": true, "has_buzzer": true } } ]
transceive and ndef/write act on the tag identified by the opaque, single-use
tag_handle from the latest nfc_tag event (Events):
curl -X POST http://10.99.0.1/api/v1/nfc/transceive \
-H 'content-type: application/json' \
-d '{"reader_id": "pn532-i2c-0", "tag_handle": "h-9f2c", "apdu": "AAQAAAI="}'
apdu/response are base64; sw is the ISO 7816 status word as 4 hex digits. A stale
tag_handle (tag left the field, reader unplugged) is 409 invalid_state; a tag that
can't perform the operation (not ISO-DEP, not NDEF-writable, capacity exceeded) is 409
nfc_tag_unsupported; an unknown reader_id/tag_handle is 404 not_found.
curl -X POST http://10.99.0.1/api/v1/nfc/reader/feedback \
-H 'content-type: application/json' \
-d '{"reader_id": "pn532-i2c-0", "led": "green", "buzzer_ms": 200}'
Wi-Fi & Bluetooth (optional, spec 12)¶
Both surfaces are double-gated, on both transports:
- Board has no integrated radio (
virt/genericmodel profile) → every/wifi/*endpoint returns503 wifi_unsupported, every/bluetooth/*endpoint returns503 bluetooth_unsupported. - Board has the radio but the config block is disabled →
503 wifi_disabled/503 bluetooth_disabled.
Check GET /device (capabilities.wifi/capabilities.bluetooth) and GET /config
before assuming either surface is usable — there is no read-only degraded mode.
| Method & path | Effect |
|---|---|
GET /wifi |
Status snapshot |
POST /wifi/scan |
Trigger a station scan (synchronous) |
GET/PUT /wifi/networks |
Configured station networks (persistent) |
POST /wifi/connect |
Connect now (ephemeral, not persisted) |
POST /wifi/ap |
Start/stop AP mode (ephemeral, not persisted) |
GET /bluetooth |
Adapter status |
POST /bluetooth/power |
Power the adapter on/off |
POST /bluetooth/scan |
Start/stop device discovery |
GET /bluetooth/devices |
Known + discovered devices |
POST /bluetooth/pair |
Pair with a device |
POST /bluetooth/remove |
Unpair a device |
{ "enabled": true, "mode": "station", "rfkill_blocked": false, "country": "FR",
"connection": { "ssid": "shop-wifi", "bssid": "dc:a6:32:01:02:03", "signal_dbm": -52, "frequency_mhz": 2437, "addresses": ["192.168.1.42"] } }
curl -X PUT http://10.99.0.1/api/v1/wifi/networks \
-H 'content-type: application/json' \
-d '[{"ssid": "shop-wifi", "psk": "correct-horse-battery-staple", "priority": 10}]'
PUT /wifi/networks is exactly equivalent to PATCH /config on wifi.networks — it
persists. connect and ap, by contrast, are runtime-only:
curl -X POST http://10.99.0.1/api/v1/wifi/connect \
-H 'content-type: application/json' \
-d '{"ssid": "shop-wifi"}'
curl -X POST http://10.99.0.1/api/v1/wifi/ap \
-H 'content-type: application/json' \
-d '{"active": true, "ap": {"ssid": "mcd-setup", "psk": "temporary-pass", "band": "2.4"}}'
curl http://10.99.0.1/api/v1/bluetooth
curl -X POST http://10.99.0.1/api/v1/bluetooth/power -H 'content-type: application/json' -d '{"powered": true}'
curl -X POST http://10.99.0.1/api/v1/bluetooth/scan -H 'content-type: application/json' -d '{"active": true}'
curl http://10.99.0.1/api/v1/bluetooth/devices
curl -X POST http://10.99.0.1/api/v1/bluetooth/pair -H 'content-type: application/json' -d '{"address": "aa:bb:cc:dd:ee:ff"}'
curl -X POST http://10.99.0.1/api/v1/bluetooth/remove -H 'content-type: application/json' -d '{"address": "aa:bb:cc:dd:ee:ff"}'
See the Wireless guide for the full station/AP setup walkthrough.
WebSocket — /api/v1/events¶
One bidirectional JSON-message socket carries events and page-channel traffic.
Subscribe by sending a client message:
Every server push uses one envelope:
You can also send a page message directly over the socket (equivalent to
POST /page/message, useful for low-latency round trips):
See Events for the full topic table and payload shapes.
Errors¶
Every non-2xx response uses the same envelope:
See the error codes reference for the full enum and when each code is returned.