Docs menu

Connect

REST API reference

The local REST API and web admin page: authentication, every endpoint, the command list, reading and writing config, rate limits and mDNS discovery.

View as Markdown llms.txt

Each tablet can run a small HTTP server on your local network. It serves a web admin page and a JSON API for state, commands and configuration. It is off by default and meant for your LAN only.

Warning. The API uses plain HTTP, so the token can be read by anyone on the same Wi-Fi. Never forward the port to the internet. See Security.

Turn it on#

  1. Open Settings, Connections, Remote access.
  2. Turn on REST API & admin page. The status chip shows Running on port 2323.
  3. Under API token tap Show or Copy. A token is created when you first turn remote access on.
  4. The Admin page line shows the address, such as http://192.168.1.50:2323/.
Setting Default Notes
REST API & admin page Off
Port 2323 1024 to 65535. Tap Apply.
API token generated 43 characters of base64url (32 random bytes). Regenerate locks out every client using the old token at once.

The server listens on all network interfaces of the tablet. The examples below use TABLET_IP for its address and $KIOSKY_TOKEN for the token.

Authentication#

Every endpoint except GET /api/info needs the token in an Authorization header:

bash
curl -H "Authorization: Bearer $KIOSKY_TOKEN" http://TABLET_IP:2323/api/state
Status Meaning
401 Missing or invalid token. The WWW-Authenticate header says Bearer.
429 Too many failed attempts. The Retry-After header gives the seconds to wait.

The one exception is GET /api/camera/stream, which also accepts HTTP Basic with the username kiosky and the token as the password, for clients such as Home Assistant's MJPEG camera that cannot send a Bearer header. Tokens in the URL query are not supported.

Errors are JSON of the form {"error": "message"}. Responses carry Cache-Control: no-store. There is no CORS, so browsers on other sites cannot call the API.

Rate limits#

  • Failed authentication is counted per client address. 5 failures within 60 seconds lock that address out for 60 seconds. Each further lockout doubles, up to 15 minutes.
  • A successful request clears the address's record.
  • A global ceiling guards against guessing from many addresses: 50 failures within 60 seconds from all clients combined lock everyone out for 60 seconds.
  • Request bodies are limited to 256 KiB (413 above that). Command payloads are limited to 4096 characters.

Endpoints#

Method Path Auth Returns
GET / none The web admin page (HTML).
GET /api/info none App, device name, id, version.
GET /api/state token Device state as JSON.
POST /api/command token Runs a command.
GET /api/config token The whole configuration as JSON.
PATCH /api/config token Changes only the fields in the body. ?dryRun=true only checks.
PUT /api/config token Replaces the whole configuration. ?dryRun=true only checks.
GET /api/screenshot token A PNG of the visible screen.
GET /api/camera/snapshot token A JPEG from the front camera.
GET /api/camera/stream token or Basic MJPEG stream.
GET /api/logs token Recent log lines as text.

GET /api/info#

Public, so a client can confirm it found a Kiosky tablet before it has a token.

bash
curl http://TABLET_IP:2323/api/info
json
{"app": "Kiosky", "name": "Hallway Tablet", "id": "hallway_tablet", "version": "0.1.0"}

id is a lower-case slug of the device name with underscores. It is the same id used in the mDNS records and in the MQTT topics (kiosk/hallway_tablet/...).

GET /api/state#

bash
curl -H "Authorization: Bearer $KIOSKY_TOKEN" http://TABLET_IP:2323/api/state
json
{
  "battery": {"levelPercent": 87, "charging": true, "temperatureC": 29.5},
  "screenOn": true,
  "brightness": 120,
  "currentUrl": "http://homeassistant.local:8123/lovelace/0?kiosk",
  "lightLux": 85.0,
  "proximityNear": null,
  "motion": false,
  "network": {"ssid": "home", "rssiDbm": -52, "ipAddress": "192.168.1.50"},
  "appVersion": "0.1.0",
  "webViewVersion": "130.0.6723.58",
  "uptimeSeconds": 86400,
  "memoryFreeMb": 812,
  "storageFreeMb": 20480,
  "screenStage": "active",
  "kioskMode": "device_owner",
  "lastInteractionEpochMs": 1763040000000,
  "kioskPaused": false,
  "kioskPausedUntilEpochMs": null
}
Field Meaning
battery levelPercent, charging, temperatureC, or null without a battery.
screenOn, brightness Screen state and current brightness (0 to 255, may be null).
currentUrl The page on screen.
lightLux, proximityNear Sensor readings, null when there is no sensor.
motion Camera motion, when motion detection is on.
network ssid, rssiDbm, ipAddress.
screenStage active, dimmed, screensaver or off.
kioskMode none, soft or device_owner.
lastInteractionEpochMs Time of the last touch.
kioskPaused, kioskPausedUntilEpochMs Whether the lock is paused and until when (null means until resumed).

MQTT publishes a flatter state with more keys (volume, dashboard, presence, tamper, voice). See the MQTT reference.

POST /api/command#

The body is a JSON object with a command name and optional args. args is either an object, or a single plain value that fills the command's main argument.

bash
curl -X POST http://TABLET_IP:2323/api/command \
  -H "Authorization: Bearer $KIOSKY_TOKEN" -H "Content-Type: application/json" \
  -d '{"command": "brightness", "args": {"level": 128}}'

# the same, with a plain value
curl -X POST http://TABLET_IP:2323/api/command \
  -H "Authorization: Bearer $KIOSKY_TOKEN" -H "Content-Type: application/json" \
  -d '{"command": "brightness", "args": 128}'
Status Body Meaning
202 {"ok": true} Accepted and queued. It does not wait for the command to finish.
400 {"error": "'level' must be 0–255"} Invalid command, missing or bad argument, or invalid JSON.
403 {"error": "Remote unlock is turned off on this tablet"} A sensitive command while remote unlock is off.
409 {"error": "Reboot requires Device Owner"} The device cannot do this.
413 Body too large.
503 {"error": "Command queue is full"} Try again shortly.

Commands#

The command names and arguments are shared by REST, MQTT and the page bridge. In the table, Main arg is the one a plain args value fills.

Command Arguments Notes
reload none Reloads the page.
load_url url (main) Must be an http(s) URL on an allowed host.
screen state: on or off (main) true, false, 1 and 0 also work.
brightness level: 0 to 255 (main)
screensaver state: on or off (main)
dashboard name (main) Name of a configured dashboard.
tts text (main, up to 1000 characters), volume 0 to 100 (optional) Speaks with Android text-to-speech.
play url (main), volume 0 to 100 (optional) Plays audio from an allowed host.
stop_audio none
volume level: 0 to 100 (main) Media volume.
toast text (main), duration 1 to 60 seconds (default 4) Shows a banner. Up to 3 show at once.
popup url (main) or camera; timeout 1 to 3600 seconds (default 60); title up to 80 characters A URL popup, or a Home Assistant camera popup with camera as one entity id or up to four, for example camera.front_door.
dismiss_popup none
snapshot none Takes a camera photo and publishes it.
microphone state: on or off (main) The voice mic privacy switch.
kiosk_lock state: on or off (main) on resumes the lock, off pauses it for 60 minutes.
kiosk_resume none Ends a pause.
kiosk_pause minutes: 1 to 1440 (main) Sensitive. Unlocks the tablet, then re-locks.
launch_app package (main, optional) Sensitive. No package opens the single-app mode app.
exit_app_mode none Sensitive. Leaves single-app mode.
restart_app none Sensitive.
clear_cache none Sensitive.
reboot none Sensitive. Needs full lock (Device Owner), else 409.

Examples:

json
{"command": "toast", "args": {"text": "Dinner is ready", "duration": 10}}
{"command": "popup", "args": {"camera": "camera.front_door", "timeout": 30}}
{"command": "popup", "args": {"url": "https://example.com/alert", "title": "Alert"}}
{"command": "dashboard", "args": "Energy"}
{"command": "kiosk_pause", "args": {"minutes": 30}}

Sensitive commands (kiosk_pause, kiosk_lock with off, launch_app, exit_app_mode, restart_app, clear_cache, reboot) are refused with 403 unless you turned on Allow remote unlock & app control on the tablet. See Security. Everything else is benign and always allowed.

Configuration#

GET /api/config#

Returns the whole configuration as pretty-printed JSON. Secrets are never in it: the HA token, MQTT password, admin PIN, API token, Immich key and webhook URL are stored separately.

bash
curl -H "Authorization: Bearer $KIOSKY_TOKEN" http://TABLET_IP:2323/api/config
json
{
  "version": 1,
  "device": {"name": "Hallway Tablet", "theme": "auto"},
  "browser": {
    "dashboards": [{"name": "Home", "url": "http://homeassistant.local:8123/lovelace/0"}],
    "allowedHosts": [],
    "reloadIntervalMinutes": 0,
    "zoomPercent": 100
  },
  "kiosk": {"enabled": true, "mode": "browser"},
  "power": {
    "keepScreenOn": "when_charging",
    "idle": {"dimAfterSeconds": 120, "screensaverAfterSeconds": 300, "offAfterSeconds": 900},
    "brightness": {"mode": "auto", "min": 10, "max": 255}
  },
  "server": {"enabled": true, "port": 2323},
  "remote": {"allowUnlock": false, "allowSecurityChanges": false}
}

The real response includes every section with all its defaults. The example above is cut for length.

PATCH /api/config#

PATCH changes only the fields you send. The body is a partial JSON object that is deep-merged onto the current configuration: objects merge key by key, while arrays and scalar values replace the current value. Anything you leave out keeps its current value. This is the safe way to make a change.

bash
# 1. check it without saving: dim after 1 minute
curl -X PATCH "http://TABLET_IP:2323/api/config?dryRun=true" \
  -H "Authorization: Bearer $KIOSKY_TOKEN" -H "Content-Type: application/json" \
  -d '{"power": {"idle": {"dimAfterSeconds": 60}}}'

# 2. save it
curl -X PATCH http://TABLET_IP:2323/api/config \
  -H "Authorization: Bearer $KIOSKY_TOKEN" -H "Content-Type: application/json" \
  -d '{"power": {"idle": {"dimAfterSeconds": 60}}}'

Because arrays replace, to change one dashboard send the whole browser.dashboards array.

Status Body Meaning
200 {"ok": true, "saved": true} Merged, saved and applied.
200 {"ok": true, "saved": false} dryRun=true: the merged config is valid, nothing was saved.
403 {"error": "protected", "message": "...", "paths": ["kiosk.enabled"]} The change touches a protected setting. Nothing was saved.
413 Body larger than 256 KiB.
422 {"error": "Config is invalid", "errors": [{"path": "power.idle.dimAfterSeconds", "message": "Must be 0–86400"}]} The body is not a JSON object (path is $), or the merged config failed validation. Nothing was saved.

The merge is repeated inside the atomic save, so a change made at the same moment to other fields is not lost. dryRun is only active for the exact value true. Always dry-run first. The remote settings cannot be changed over REST.

PUT /api/config#

PUT replaces the entire configuration with the body. Fields you leave out go back to their defaults, they are not kept. Use it only when you really want to replace everything, for example to restore a backup: GET /api/config, edit, then PUT the full document. It supports ?dryRun=true and returns the same statuses as PATCH.

bash
curl -s -H "Authorization: Bearer $KIOSKY_TOKEN" http://TABLET_IP:2323/api/config > config.json

# restore or replace: check first, then save
curl -X PUT "http://TABLET_IP:2323/api/config?dryRun=true" \
  -H "Authorization: Bearer $KIOSKY_TOKEN" -H "Content-Type: application/json" \
  --data @config.json
curl -X PUT http://TABLET_IP:2323/api/config \
  -H "Authorization: Bearer $KIOSKY_TOKEN" -H "Content-Type: application/json" \
  --data @config.json

If a PUT body has no remote key, the tablet keeps its own remote settings. Files with a newer version than the app supports are rejected.

Protected settings#

Some settings decide who can control or reach the tablet. A PUT or PATCH that changes any of them returns 403 and saves nothing, unless Allow remote security changes is on in the tablet's settings. The remote settings themselves can never be changed over REST.

Path prefix Why
kiosk The lock, single-app mode, allowed apps.
remote The switches that govern remote control. Never over REST.
server The REST server itself (enabled, port).
camera.stream The camera stream.
homeAssistant.baseUrl Where the HA token is sent.
browser.injections Scripts that run in your pages.
browser.allowedHosts What the browser may reach.
browser.bridgeOrigins, browser.mediaOrigins Pages that get the bridge or the microphone.
mqtt.host, mqtt.port, mqtt.tls, mqtt.username, mqtt.publishNetworkInfo Where MQTT credentials and state go.

Changing the Home Assistant origin or the MQTT host also deletes the saved HA token or MQTT password, so a secret never follows a changed address.

Config reference#

These are the settings most often changed, with their defaults. Times are local HH:mm.

Path Default Notes
device.name "Kiosky" 1 to 64 characters. Drives topic and mDNS names.
device.theme "auto" auto, light, dark.
browser.dashboards[] [] {"name", "url"}, unique names.
browser.allowedHosts[] [] Protected. See allowed hosts.
browser.reloadIntervalMinutes 0 0 to 1440.
browser.zoomPercent 100 25 to 500.
browser.pullToRefresh, desktopMode, swipeDashboards, homeButton false
browser.rotation off enabled, intervalSeconds (5 to 3600, default 60), pauseSeconds (0 to 3600, default 60), dashboards[].
browser.planner[] [] {"dashboard", "from", "to", "days"}, up to 24 rules.
kiosk.enabled false Protected. Locked or normal app.
kiosk.mode "browser" Protected. browser or app.
kiosk.app none Protected. {"package", "activity"}.
kiosk.normal see below Protected. startOnBoot true, showOnCommand true, returnOnScreenOff false.
power.keepScreenOn "when_charging" never, when_charging, always.
power.idle 120 / 300 / 900 dimAfterSeconds, screensaverAfterSeconds, offAfterSeconds (0 turns a stage off).
power.brightness auto, 10, 255 mode (auto or manual), min, max.
power.night off from 22:30, to 06:30, action (dim, off, url), days 1 to 7, url.
power.battery off enabled, minPercent 40, maxPercent 80.
wake.touch, wake.motion true, false
screensaver.type "clock" none, clock, photos.
screensaver.photos folderUris, immich, urls, intervalSeconds 30, shuffle true, fit, kenBurns, showClock.
homeAssistant off enabled, baseUrl (protected), theme, themeMode.
mqtt off enabled, host, port 1883, tls, username, baseTopic "kiosk", discovery true, publishIntervalSeconds 60.
server off, 2323 Protected.
maintenance none restartAppAt, rebootAt.
camera.motion off enabled, sensitivity 0.5, fps 2, snapshotOnMotion.
camera.face off enabled, wakeOnFace, fps 2, holdSeconds 30.
camera.tamper off enabled, sensitivity 0.5.
camera.stream off Protected. enabled, fps 2, maxEdgePx 480, quality 60.
voice off enabled, wakeWord "okay_nabu", micMuted, listenOnlyWhenScreenOn true, listenOnlyWhenCharging false, showTranscript, publishLastCommand, chime, pipelineId.
remote both false allowUnlock, allowSecurityChanges. Never over REST.

Other endpoints#

Screenshot#

GET /api/screenshot returns a PNG of the screen (image/png), or 503 with "No visible screen to capture" when nothing is on screen.

bash
curl -H "Authorization: Bearer $KIOSKY_TOKEN" http://TABLET_IP:2323/api/screenshot -o screen.png

Camera#

GET /api/camera/snapshot returns a JPEG no older than a couple of seconds, or 503 ("Camera unavailable"). GET /api/camera/stream returns multipart/x-mixed-replace MJPEG. It answers 404 ("Camera stream is disabled") unless the stream is turned on, and 503 when 2 viewers are already connected or the camera does not start within 15 seconds. See Camera & sensors.

Logs#

GET /api/logs?lines=200 returns plain text. lines defaults to 500 and is limited to 1 to 2000. Tokens and passwords are masked.

Admin page#

Open http://TABLET_IP:2323/ in a browser, paste the API token and tap Connect. The page offers quick actions (reload, screen on and off, restart app, brightness, load a URL), live state, and a JSON editor for the configuration with Reload from device, Validate and Save. It talks to the same API.

Discover tablets on the network (mDNS)#

While the server runs, the tablet announces itself over mDNS/DNS-SD as both _kiosk._tcp and _http._tcp. The service name is the device name (Android may append (2) if there is a conflict), and the TXT records are:

Key Value
id The slug, such as hallway_tablet.
version The app version.
path /

Android cannot publish a .local host name, so resolve the service name to an address and port instead:

bash
# macOS
dns-sd -B _kiosk._tcp local
dns-sd -L "Hallway Tablet" _kiosk._tcp local

# Linux
avahi-browse -rt _kiosk._tcp

Then call GET /api/info on the address and port to confirm. If mDNS is blocked on your network, use the IP address shown in the app (Settings, Device, or Connections, Remote access) and the port.

Home Assistant without MQTT#

You can drive a tablet from Home Assistant with rest_command:

yaml
rest_command:
  kiosky_hallway_toast:
    url: "http://192.168.1.50:2323/api/command"
    method: POST
    headers:
      Authorization: !secret kiosky_hallway_bearer   # "Bearer <token>"
      Content-Type: application/json
    payload: '{"command": "toast", "args": {"text": "{{ message }}", "duration": 8}}'

Keep the token in secrets.yaml, never in a file you share.