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.
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#
- Open Settings, Connections, Remote access.
- Turn on REST API & admin page. The status chip shows Running on port 2323.
- Under API token tap Show or Copy. A token is created when you first turn remote access on.
- 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:
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 (
413above 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.
curl http://TABLET_IP:2323/api/info
{"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#
curl -H "Authorization: Bearer $KIOSKY_TOKEN" http://TABLET_IP:2323/api/state
{
"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.
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:
{"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.
curl -H "Authorization: Bearer $KIOSKY_TOKEN" http://TABLET_IP:2323/api/config
{
"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.
# 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.
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.
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:
# 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:
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.