# 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](security.md).

## 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](#commands). |
| 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](mqtt.md#state).

### 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](dashboards.md#allowed-hosts). |
| `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](security.md#remote-control-policy). 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](dashboards.md#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](camera-sensors.md#live-stream).

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