Skip to content

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
curl http://10.99.0.1/api/v1/device
{
  "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 http://10.99.0.1/api/v1/device/health
curl "http://10.99.0.1/api/v1/device/logs?limit=50"
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.

curl http://10.99.0.1/api/v1/network
{ "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.

curl http://10.99.0.1/api/v1/display
{ "mode": "url", "url": "https://pos.example.com/recap", "screen_on": true, "rotation": 0, "resolution": "1920x1080@60" }
curl http://10.99.0.1/api/v1/display/screens
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 http://10.99.0.1/api/v1/display/screenshot -o frame.png
curl -X POST http://10.99.0.1/api/v1/display/execute-js \
  -H 'content-type: application/json' \
  -d '{"script": "document.title"}'
{ "result": "Order recap" }

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).

curl http://10.99.0.1/api/v1/page/status
{ "open": true }

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.

curl http://10.99.0.1/api/v1/content
{
  "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.

curl -X POST http://10.99.0.1/api/v1/content/promo/check
{ "ok": true, "message": "ok" }

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.

curl -X DELETE http://10.99.0.1/api/v1/content/promo

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 http://10.99.0.1/api/v1/config
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
curl http://10.99.0.1/api/v1/update
{ "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:

curl -X POST http://10.99.0.1/api/v1/update/apply

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

curl http://10.99.0.1/api/v1/operations/op-7
{ "operation_id": "op-7", "kind": "content_upload", "state": "running", "progress": 0.42 }

404 operation_not_found for an unknown/expired id.

Input — detected devices

curl http://10.99.0.1/api/v1/input/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
curl http://10.99.0.1/api/v1/nfc/readers
[ { "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="}'
{ "response": "kA==", "sw": "9000" }

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/generic model profile) → every /wifi/* endpoint returns 503 wifi_unsupported, every /bluetooth/* endpoint returns 503 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
curl http://10.99.0.1/api/v1/wifi
{ "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 POST http://10.99.0.1/api/v1/wifi/scan
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.

websocat ws://10.99.0.1/api/v1/events

Subscribe by sending a client message:

{ "type": "subscribe", "topics": ["display_state", "operation", "page_message", "health"] }

Every server push uses one envelope:

{ "type": "event", "topic": "page_message", "ts": "2026-06-12T10:21:32Z", "payload": { } }

You can also send a page message directly over the socket (equivalent to POST /page/message, useful for low-latency round trips):

{ "type": "page_message", "payload": { "cart_total": 42.50 } }

See Events for the full topic table and payload shapes.

Errors

Every non-2xx response uses the same envelope:

{ "error": { "code": "content_not_found", "message": "no bundle named 'promo'", "details": {} } }

See the error codes reference for the full enum and when each code is returned.