Reference
API Reference
The editor, the remote and every display talk to the hub through its API under /api/. This page documents the endpoints you would call yourself from a script, a bookmark or a home automation system such as Home Assistant: display control, chores, meals, timers and backups. Every other endpoint is listed under All endpoints with the access it needs. Those are the app's own plumbing, and the route file under src/app/api/ is the reference for each one.
Access
Most endpoints are protected, but only once you set a password in Settings > Security. On a fresh install with no password, every check described here does nothing and the whole API is open to anyone on your network. That is the default, and it is why the endpoints below say "requires a valid session" rather than "always requires a valid session".
There are three levels of protection:
| Level | What it accepts | Used by |
|---|---|---|
| Session | The hs-session cookie set by POST /api/auth/login | Endpoints that change settings or read credentials |
| Display | The session cookie or a display token | Everything a screen polls: config, weather, calendar, commands, chore data |
| Media | Display access or a signed mt token bound to one file | The routes that serve photos and videos into <img> and <video> tags |
Endpoints below that say "requires a valid session" are the first level. Endpoints that say "display access" are the second, and a browser session works for those too.
A few endpoints are open at every level on purpose, and each one says so where it appears: GET/POST /api/chores, POST /api/chores/grab and GET/POST /api/rewards (so the kid-facing /chores page keeps working when a password is set), GET /api/plugins/registry, GET /api/system/build-id, GET /api/health, GET /api/i18n/<locale> (the UI's words, needed before anyone signs in), and GET /api/plugins/auth/callback.
Is the hub up?
GET /api/health answers from any device on your network, with or without a password, and the IP allowlist never blocks it (the only other endpoint it always lets through is GET /api/auth/status, which the login page needs). It says which version is running and nothing else:
{"ok":true,"version":"1.13.0","build":"Xb2kQ7pL0sR4","schema":16,"uptime":864}
uptime is seconds since the hub started, and schema is the newest settings format this release can read. Home Screens uses this route itself: an update is only kept once the new version answers here, and a display-only Pi checks it before showing the connecting screen. It is also the first thing to try when a display is blank, see Troubleshooting.
Using the display token
GET /api/auth/display-token returns the token (session required). Send it as a header:
Authorization: Bearer hs_abc123...
For bookmarkable links, the /api/display/* routes also accept the token as a ?token= query parameter, for example /api/display/wake?display=kitchen&token=hs_abc123.... That shortcut is scoped to /api/display/* and nowhere else; every other endpoint needs the header or the session cookie. Query tokens end up in browser history and server logs, so use the header wherever you can send one.
If you have listed networks under Settings > Security > Allowed networks with Let displays on these networks in without the key turned on, GET requests from those addresses clear display access with no token at all, bookmarked commands such as /api/display/wake included. A POST from those addresses still needs the token or a session cookie.
Cross-origin writes
Every POST, PUT, PATCH and DELETE under /api/ is refused with 403 when the request carries an Origin header that does not match the hub's own address. Browsers add that header to every cross-site request and page scripts cannot remove it, so a web page on some other site cannot make a browser in your house change your settings, even on an install with no password. Requests with no Origin header at all are allowed, so curl, Home Assistant, scripts and the per-Pi reporter are unaffected. GET and HEAD are never checked.
If you call the API from a page served somewhere else on purpose (a dashboard on another host, for example), list that page's origin in the HS_ALLOWED_ORIGINS environment variable of the Home Screens service, comma-separated. A full origin (http://dashboard.local:8123) or a bare host (dashboard.local) both work; the comparison is on the host.
Display Control
Remote control endpoints for the kiosk display. The display polls for pending commands; the editor or any HTTP client can enqueue commands.
If you're scripting a display, a Home Assistant automation, a bookmark on your phone, the endpoints you want are /api/display/:command (wake, sleep, next-screen, prev-screen), goto-screen, brightness, sleep-override, profile, module-enabled (show or hide one module), alert, module-command (next story on a news module), and show-photo (put a picture or video from the library on the screen). For a ready-made Home Assistant package built on them, voice sentences included, see the Voice Control guide. The rest of this section documents the protocol the kiosk itself speaks and is marked Client protocol where it applies; you only need it if you're writing your own display client.
Targeting a display (multi-display)
When the hub has more than one display registered, every display-control endpoint accepts an optional display target. There are two ways to provide it:
- Query string: append
?display=<id>(works on GET and POST). Useful for bookmarkable simple commands like/api/display/wake?display=kitchen. - JSON body field:
{ "displayId": "<id>", … }(POST only).
Use the reserved word all as the display target to broadcast to every display that has checked in since the hub started, plus the legacy default queue. Broadcast is allowed for command-enqueue actions (simple commands, brightness, sleep-override, alert, module-command, show-photo) and rejected for read-only or mutate-config actions (status, profile, module-enabled). It is also rejected for goto-screen, even though that enqueues a command, because screen sets differ per display and a broadcast jump would be meaningless on most of them.
A command or a status read with no display target goes to the main display (the display with ID main, or the first one listed if none has that ID). On a single-display install, with no displays registered, it goes to that one display, so bookmarks and scripts written before you added a second display keep reaching the first one. The display's own calls (its command poll and its status report) are the exception: a display always names itself. See the Multi-display guide for the full multi-display setup.
Bookmarks need a token once you set a password. Every endpoint in this section is protected at the display level, and a link you tap from your phone can't send an Authorization header. Add the display token to the link instead:
/api/display/wake?display=kitchen&token=hs_abc123...
Get the token from GET /api/auth/display-token. This ?token= shortcut works on /api/display/* and nowhere else; anything outside that path needs the header or a signed-in browser session. With no password set, none of this is needed and the plain URL works.
GET /api/displays
Read-only registry of all configured displays plus runtime heartbeat data the hub has collected from polling display-only Pis. Used by the editor's Per display > All displays page and by display-only Pis waiting for adoption. Display access.
The configuration behind this response is re-read at most every 1.5 seconds, so a change you just saved can take one poll cycle to show up here.
| Parameter | Type | Description |
|---|---|---|
id | string | Optional. When provided, returns a minimal {adopted, displayId} shape used by display-only Pis to poll for adoption. Without id, returns the full registry. |
Response (full):
{
"displays": [
{
"id": "kitchen",
"name": "Kitchen",
"screenCount": 3,
"activeProfile": "evening",
"settings": { "rotationInterval": 30, "transitionEffect": "fade" },
"displayWidth": 1080,
"displayHeight": 1920,
"displayTransform": "90",
"lastSeen": 1709913600000,
"reportedViewport": { "width": 1080, "height": 1920 },
"viewportReports": [
{ "width": 1080, "height": 1920, "address": "192.168.86.187", "tabCount": 1 }
],
"status": {
"currentScreen": { "id": "abc-123", "name": "Main" },
"displayState": "active",
"activeProfile": null
}
}
],
"unadopted": [
{ "id": "home-screens-hysd", "lastSeen": 1709913600000, "reportedViewport": { "width": 1920, "height": 1080 } }
]
}
Response (?id=kitchen):
{ "adopted": true, "displayId": "kitchen" }
settings is that display's full set of per-display overrides: rotation interval, transitions, sleep schedule, screensaver, theme, alerts, and so on. It only contains the values that display actually overrides. lastSeen is null, rather than missing, for a display that has never checked in.
This endpoint is read-only, all writes go through PUT /api/config so undo/redo and validation stay consistent.
GET /api/display/commands
Client protocol. Returns and drains all pending commands from the queue. The display polls this endpoint every 3 seconds as its heartbeat. Pass ?display=<id> to drain a specific display's queue; without it the legacy default queue is drained.
Response:
{
"commands": [
{ "type": "wake" },
{ "type": "brightness", "payload": { "value": 50 } }
],
"sharedStateWatched": false,
"revisions": {
"buildId": "Xb2kQ7pL0sR4",
"plugins": "9f1c0a7e5d3b2c41",
"config": "\"4e0f9a1c.America%2FChicago\"",
"timer": "c02d6b19e8a4f7a0",
"chores": "\"b1e7d05c9a3f28e64d0c7a19\"",
"rewards": "\"7f20c4a9e15d3b86c0f94e2a\"",
"library": "mfh3k2a1.4",
"calendar": "mfh3k2a1.0"
}
}
revisions says whether anything else on the screen changed: the running build (a new one reloads the page), the installed plugin list including plugin settings (GET /api/plugins/installed), the config (config is exactly the ETag that GET /api/config?display=<id> would answer with), the timer session (GET /api/timers/session), and the chore history and rewards (chores and rewards are exactly the ETags that GET /api/chores and GET /api/rewards would answer with). The display fetches each of those only when its value differs from the one it last applied.
library and calendar speed up reads that keep their own polling. library moves when a picture or video is uploaded, imported, replaced, moved, renamed or deleted; calendar moves when a Google Calendar sign-in or disconnect changes what the hub can read, or when the household switches between its own Google app and Home Screens' app on the API keys page. Once a new value has held for a whole beat, the display re-reads its slideshow lists or its calendar at once instead of waiting for their next 10-minute or 5-minute poll, so a burst of uploads from a phone causes one re-read. Both are counters the hub keeps in memory, so a hub restart changes them once and every display re-reads. A value the hub could not work out is left out, and the display keeps what it has for it.
sharedStateWatched tells the screen whether anyone is currently watching its shared values in the editor. While it is true, the screen reports changes as they happen instead of waiting for its next 30-second heartbeat, so the editor sees live values; while it is false, it stays on the slower schedule. A display client that ignores this flag still works, it just never speeds up.
GET /api/display/revisions
The revisions object from the command drain on its own, { "revisions": { ... } }, without draining anything or counting as the display checking in. The editor's Preview window uses it to follow edits without taking the real display's commands. Display access.
GET /api/display/status
Returns the last-known display status as reported by the display client. Accepts ?display=<id> for the multi-display case; without a target, it returns the main display's status (or the one display's, on a single-display install). Before the first heartbeat arrives this returns 200 with null, because a display that has not reported yet is a normal state on a new install, not an error.
Response:
{
"currentScreen": { "index": 0, "id": "abc-123", "name": "Main" },
"screenCount": 3,
"activeProfile": "evening",
"profileScheduled": false,
"displayState": "active",
"timestamp": 1709913600000,
"lastSeen": 1709913600000,
"reportedViewport": { "width": 1080, "height": 1920 },
"hwStats": { "piModel": "Raspberry Pi 5 Model B Rev 1.0", "cpuTempC": 52.1, "load1": 0.4, "memoryTotal": 4045000704, "memoryFree": 2100000000, "packages": { "chromium": "1:141.0.7390.54-1~deb12u1" }, "...": "see below" },
"browserStats": { "viewportWidth": 1080, "viewportHeight": 1920, "userAgent": "...", "timezone": "America/Chicago" }
}
activeProfile is the profile whose screens the display is showing right now, or null when every screen rotates. While a scheduled profile's time is on, that is the scheduled profile and profileScheduled is true; the profile picked by hand (from POST /api/display/profile or the editor) takes over when the schedule ends.
hwStats is present only when the per-Pi reporter has posted to /api/display/hw-stats. browserStats is present once the display has sent at least one heartbeat from a modern client; older clients omit it.
GET /api/display/shared-state
Returns the most recent snapshot of a display's shared values, the named values plugins publish and that modules can be shown or hidden by. The editor polls this while a visibility-conditions panel is open so it can show what each value currently is on the real screen.
| Parameter | Type | Description |
|---|---|---|
display | string | Which display's snapshot to read. Without it, the main display's (or the one display's, on a single-display install) |
Response:
{
"entries": {
"livingroom_motion": { "value": "on", "updatedAt": 1709913600000 },
"front_door": { "value": "closed", "updatedAt": 1709913000000, "staleAt": 1709913500000 }
},
"reportedAt": 1709913600000,
"providerHealth": {
"home-assistant": { "ok": false, "message": "Can't reach Home Assistant", "since": 1709913000000 }
}
}
An entry with staleAt is one whose publisher has let go of it: the last known value is kept for a short grace window so a plugin restarting doesn't make conditioned modules blink, and the editor badges it as no longer updating.
A display that hasn't reported yet returns { "entries": {}, "reportedAt": null } rather than a 404, because having no snapshot yet is a normal state while a screen is starting up, not an error. You'll get the same empty answer for a display that has stopped reporting for more than five minutes. providerHealth only appears when a plugin publishing these values is currently having trouble, and lists only the ones that are.
Polling this endpoint is also the signal that someone is watching. It marks the display as watched for the next 15 seconds, which turns on the sharedStateWatched flag in that display's next command drain and switches it to reporting changes as they happen. Stop polling and it drifts back to the 30-second heartbeat on its own.
GET /api/display/:command
POST /api/display/:command
Simple commands, bookmarkable via GET or scripted via POST. Supported commands: wake, sleep, next-screen, prev-screen, reload, clear-alerts. Pass ?display=<id> or include displayId in a POST body to target a specific display; ?display=all broadcasts.
Response: { "ok": true, "command": "wake" }
POST /api/display/brightness
Sets the display brightness.
Body: { "value": 50 } (0-100)
Response: { "ok": true, "command": "brightness", "value": 50 }
POST /api/display/goto-screen
Jumps the display straight to a specific screen, the command behind "show the calendar" in the Voice Control guide and the Display Control module's screen buttons.
Body: { "screen": "calendar", "displayId": "kitchen" } (displayId optional)
screen is a screen id or name; the display client resolves it against its own rotation, matching the id first and then the name case-insensitively. The hub can't validate the target (screen sets are per-display and the queue never reads config), so an unknown target still returns ok here and is ignored with a console warning on the display. A screen excluded from the current rotation, for example by the active profile, is also ignored rather than jumped to. Does not accept all as a target; an empty or missing screen returns 400.
Response: { "ok": true, "command": "goto-screen", "screen": "calendar" }
POST /api/display/module-command
Pokes every module of one type on the display. The hub just relays it; the module decides what the action means. Today the news modules understand next, prev (move to the next or previous story or page), details (open the current story's summary), and dismiss (close it), so "next story" can be a voice command or a button. Broadcast with ?display=all is allowed.
Body: { "module": "news", "action": "next", "displayId": "kitchen" } (displayId optional; module is news or fullscreen-news; an optional value string or number is passed through)
module and action must be short lowercase names (letters, digits, -, _, :); anything else returns 400.
Response: { "ok": true, "command": "module-command", "module": "news", "action": "next" }
POST /api/display/show-photo
Puts one picture or video from the library over the screen until its time is up or someone taps it, the same as Show on the wall in the phone's Photos. Broadcast with ?display=all is allowed.
Body: { "file": "family/beach.jpg", "duration": 60, "displayId": "kitchen" } (duration in seconds, optional, 60 by default, kept between 5 and 600; displayId optional)
file is the path inside the library, for example family/beach.jpg: the file= value in each link GET /api/backgrounds returns, not the link itself. Anything that is not a picture or video in the library returns 400, and a file that is not there returns 404. The hub works out the link itself, so a request can only ever show a file that is already in the library.
Response: { "ok": true, "command": "show-photo", "durationMs": 60000 }
POST /api/display/sleep-override
Wakes the display and holds off the automatic sleep machinery, the sleep schedule, the dim schedule, and idle transitions, for a number of minutes. This is "keep the display on tonight": use it when you need a specific, longer duration. A plain wake during a scheduled sleep or dim window also holds the display awake, but only for the display's "After a wake-up, stay on for" setting (5 minutes by default). Holds only ever extend, a shorter wake never cuts an existing longer hold short. An explicit sleep command (or brightness 0) cancels the hold early. Broadcast with ?display=all is allowed.
Body: { "minutes": 480 } (1 to 1440, anything longer than 24 hours is rejected with 400)
Response: { "ok": true, "command": "sleep-override", "minutes": 480 }
POST /api/display/profile
Switches the active profile. Persists the selection to the config file. A scheduled profile still wins while its time is on, and the profile picked here takes over when it ends. Display access, a display token is enough, same as the other command verbs, so a Home Assistant automation can switch profiles; the write only touches the active-profile pointer, the same value the display's own rules engine flips. Accepts ?display=<id> or displayId in the body; does not accept all (profile switches are per-display).
Body: { "profile": "profile-id", "displayId": "kitchen" } (displayId optional)
An unknown display returns 404 { "error": "Unknown display: <id>" }, and a profile that display can't use returns 404 { "error": "Unknown profile: <id>" }. The config is updated in one read-modify-write step, so switching a profile here can't wipe out an editor save happening at the same moment.
Response: { "ok": true, "profile": "profile-id", "displayId": "kitchen" }
POST /api/display/module-enabled
Shows or hides one module, the same switch as Hide and Show on a module in the editor. The change is saved to the config file, so a hidden module stays hidden until something shows it again, and displays pick it up within a few seconds. Display access, a display token is enough, so a Home Assistant automation can hide a module without the editor password; the display token cannot save the whole layout with PUT /api/config, so this is the way to do it. Does not accept all as a target.
Body: { "moduleId": "clock-1", "enabled": false, "displayId": "kitchen" } (enabled optional, leave it out to toggle; displayId optional)
moduleId is the module's id from GET /api/config, under screens[].modules[] on a single display or displays[].screens[].modules[] once you have more than one. Without displayId the hub looks on every display; with it, only on that display's screens. enabled must be true or false, anything else returns 400 rather than toggling. An unknown module returns 404 { "error": "Unknown module: <id>" } and an unknown display returns 404 { "error": "Unknown display: <id>" }.
The config is updated in one read-modify-write step, so this can't wipe out an editor save happening at the same moment. An editor that is open with unsaved changes will say the layout was changed somewhere else when it next saves; choosing Keep mine there puts the module back the way the editor had it.
Response: { "ok": true, "command": "module-enabled", "moduleId": "clock-1", "enabled": false } (enabled is the module's state after the call, which is how you read the result of a toggle)
POST /api/display/alert
Shows an alert overlay on the display.
Body:
{
"type": "info",
"title": "Alert Title",
"message": "Alert message body",
"duration": 10000,
"icon": "bell",
"dismissible": true
}
The type field accepts info, warning, or urgent; anything else quietly becomes info rather than failing. The icon, duration, and dismissible fields are optional. duration is in milliseconds; send 0 and the alert stays up until it is dismissed, whatever its type. Omit it and the display's default duration from Settings > Screen > Alerts applies; if that isn't set either, the per-type defaults kick in, 10 seconds for info, 30 seconds for warning, and urgent stays up until dismissed. At least one of title or message is required; send neither and you get 400 { "error": "title or message required" }.
Response: { "ok": true, "command": "alert" }
POST /api/display/status
Client protocol. Reports the current display state. Called by the browser display client every 30 seconds (and on any state change). Goes through withDisplayAuth: the kiosk sends a display bearer token on every request.
Body:
{
"displayId": "kitchen",
"clientId": "tab-a1b2",
"currentScreen": { "index": 0, "id": "abc-123", "name": "Main" },
"screenCount": 3,
"activeProfile": null,
"profileScheduled": false,
"displayState": "active",
"timestamp": 1709913600000,
"browserStats": {
"viewportWidth": 1080,
"viewportHeight": 1920,
"userAgent": "Mozilla/5.0 ...",
"timezone": "America/Chicago"
},
"sharedState": {
"livingroom_motion": { "value": "on", "updatedAt": 1709913600000 }
},
"providerHealth": {
"home-assistant": { "ok": false, "message": "Can't reach Home Assistant", "since": 1709913000000 }
}
}
Required fields: currentScreen (object with id string), displayState (one of active / dimmed / asleep: anything else is rejected), and timestamp (number). displayId is the display-registry ID; omit it in legacy single-display mode. clientId distinguishes multiple tabs reporting under the same display ID so the editor can surface "which tab is phantom-heartbeating."
browserStats is optional, when present, its viewportWidth/viewportHeight are also mirrored into a legacy top-level reportedViewport field so older consumers keep working. Any hwStats field sent to this endpoint is silently dropped, hardware telemetry now posts to /api/display/hw-stats instead.
sharedState and providerHealth are both optional and both ride along on the heartbeat rather than having endpoints of their own. sharedState is this tab's snapshot of its shared values and providerHealth reports any plugin currently failing to supply them. Neither is stored with the rest of the status; they go into their own store and come back out of GET /api/display/shared-state.
Response: { "ok": true }
POST /api/display/hw-stats
Client protocol. Accepts a hardware-stats snapshot from a display Pi's bash reporter (scripts/reporter.sh, on a systemd timer). Adoption IS authorization: the endpoint is not wrapped in withDisplayAuth because the reporter runs on a separate Pi that can't reasonably carry a display bearer token. Instead, the displayId must appear in config.displays (or be the literal main in legacy single-display mode). The LAN is the trust boundary.
Body:
{
"displayId": "kitchen",
"hwStats": {
"piModel": "Raspberry Pi 5 Model B Rev 1.0",
"cpuModel": "Cortex-A76",
"cpuCores": 4,
"cpuTempC": 52.1,
"load1": 0.4,
"load5": 0.3,
"load15": 0.25,
"throttled": { "raw": "0x0", "active": false, "underVoltage": false, "previouslyThrottled": false },
"memoryTotal": 4045000704,
"memoryFree": 2100000000,
"diskTotal": 31000000000,
"diskFree": 24000000000,
"kioskUpdater": true,
"kioskVersion": "1.13.0",
"packages": { "chromium": "1:141.0.7390.54-1~deb12u1", "labwc": "0.8.4-1", "kanshi": "1.7.0-1", "wlopm": "0.1.0-1" },
"packagesCheckedAt": "2026-10-10T03:12:00Z",
"reportedAt": "2026-10-10T18:00:00.000Z"
}
}
packages holds the installed versions of Chromium, labwc, kanshi and wlopm, and packagesCheckedAt when the Pi last looked for newer ones; both are null from a reporter that predates them. An unrecognized shape is refused with 400.
Response: { "ok": true }. Returns 403 with Display '<id>' is not adopted when the displayId isn't in the registry.
POST /api/display/console-log
Client protocol. Uploads a batch of browser console entries for a display. Called by the kiosk in response to a dump-console-log command (which /api/system/diagnostics broadcasts before composing its bundle). Goes through withDisplayAuth.
Body:
{
"displayId": "kitchen",
"entries": [
{ "level": "log", "message": "...", "timestamp": 1709913600000 },
{ "level": "warn", "message": "...", "timestamp": 1709913601000 },
{ "level": "error", "message": "...", "timestamp": 1709913602000 }
]
}
Entries are capped at 500 per request and messages at 2 000 characters each (longer messages are truncated). Only the three log levels log / warn / error are accepted; anything else is silently dropped.
Response: { "ok": true, "stored": 42 }
GET /api/display/kiosk-bundle
Client protocol. Serves a display-only Pi its shell layer (kiosk launcher, splash page, reporter, systemd units) so display-only Pis keep themselves up to date with the hub. The display-only Pi's kiosk-update.sh polls this endpoint hourly and on every Chromium start, verifies the checksum, and swaps files in place with a rollback copy. Uses the same adoption gate as /api/display/hw-stats: the display ID must appear in config.displays, and an unadopted display-only Pi gets a 403 that its updater treats as "no update".
| Parameter | Type | Description |
|---|---|---|
display | string | The display-only Pi's display ID. Required. |
manifest | string | Set to 1 to get the JSON manifest instead of the tarball |
Response: with manifest=1, { "version", "sha256", "restartAdvised", "files": [...] }: restartAdvised is computed on the hub from the display's sleep state, so a display someone is looking at is never told to restart itself mid-evening. Without it, the deterministic application/gzip tarball itself. Returns 503 when the hub can't compose a bundle, which the display-only Pi reads as "try again later".
GET /api/display/kiosk-bootstrap
Client protocol. Returns a one-shot shell script that puts a Pi installed before self-update existed onto the self-updating path. The editor surfaces the copy-paste line next to any display that has never reported its display-software version:
curl -fsS "http://<hub>:3000/api/display/kiosk-bootstrap?display=<id>" | bash
After it runs once, that Pi updates itself from /api/display/kiosk-bundle from then on. Same adoption gate as the bundle endpoint; because the response is piped into bash, errors come back as shell echo/exit lines rather than JSON.
GET /api/display/power-state
Client protocol. Polled by the panel power helper in a Pi's kiosk session (scripts/kiosk-power-agent.sh): every 3 seconds while the screen is off, so a wake lights it at once, and every 15 seconds while it is on. Answers whether that display's screen should be powered right now:
curl "http://<hub>:3000/api/display/power-state?display=kitchen&applied=on"
{ "power": "off" }
off only when all three hold: the display's sleep settings have Switch the screen's power off too turned on, its browser's last heartbeat said asleep, and that heartbeat is less than 90 seconds old. Anything else, including a display that has never reported, answers on, so a crashed browser always gets its screen back. The optional applied parameter (on or off) is the state the helper currently has set; the hub records it with the display's status so the editor can show whether screen power control is working. Same adoption gate as hw-stats; in single-display mode, display=main reads the hub's own display.
Chores
GET /api/chores
Returns chore completion records, the bonus chores someone has grabbed, and when each "when I put it back" bonus chore was last put back. Automatically purges entries older than 90 days. Public on the LAN with no authentication so the kid-facing /chores view works even when the editor password is set.
Query: days (optional, a whole number from 1 to 90) sends only that many days of history before today. Completions that still keep a "when I put it back" chore done, and grabs that still hold, come whatever their age, so today and this week look the same as with the whole history. The wall and the kids' page ask for ?days=31, which covers a 30-day streak. Anything else is a 400. chores=1 (optional) adds the chore list as chores, its choresRevision, and the family: the phone and the kids' page send it so they notice a chore or a person another phone changed.
Caching: the answer carries an ETag and Cache-Control: no-cache, so a browser checks before reusing it. A request whose If-None-Match matches gets an empty 304. The ETag is the same for every days, and changes when the chores, their settings or the completions are saved, when the household's day changes, or after an update. GET /api/rewards works the same way for the rewards, balances and redemptions.
Response:
{
"completions": [
{ "choreId": "chore-1", "memberId": "member-1", "date": "2026-03-08", "at": "2026-03-08T07:42:10.000Z" },
{ "choreId": "chore-2", "memberId": "member-1", "date": "2026-03-08", "status": "skipped" }
],
"grabs": [
{ "choreId": "chore-9", "memberId": "member-2", "date": "2026-03-08" }
],
"bonusResets": { "chore-10": "2026-03-01T18:00:00.000-06:00" },
"today": "2026-03-08",
"settings": { "grabLimit": 1, "grabHold": "day" }
}
today is the household's calendar day: today in the time zone set in Settings (the hub's own clock when none is set). Every chore route uses it, and the family remote, the kids' page, the wall and the card take it as their today, so a screen with a wrong clock or in another time zone still shows the household's day.
A completion with "status": "skipped" is a chore a grown-up marked "not today": it pays nothing and counts as neither done nor missed. at is when the entry was made; older entries do not have it.
POST /api/chores
Toggles a chore completion. If the completion already exists for the given choreId + memberId + date, it is removed; otherwise it is added. Public on the LAN (no session required) so kids can mark chores done from the /chores view.
Body:
{
"choreId": "chore-1",
"memberId": "member-1",
"date": "2026-03-08",
"direction": "complete"
}
The date field must be a real YYYY-MM-DD calendar date within the last 90 days. Future dates, invalid calendar dates (e.g. 2026-02-30), and dates outside the retention window are rejected with 400. Toggling a chore with a non-zero point value also credits or debits the member's reward balance; if removing a past completion would drive the balance negative, the response includes overspent with the memberId and the new negative balance, and the client words the message in its own language.
direction is optional. Omitted, the call is the flip described above. Set to "complete" or "uncomplete", the call only ever moves the chore in that direction and is a no-op when it's already there, so a repeated "mark it done" (a voice assistant, a retried request) can never accidentally un-complete a chore and take the points back. Any other value is rejected with 400.
Response:
{
"completions": [ ... ],
"changed": true,
"rewards": { "rewards": [ ... ], "balances": { "member-1": 122 }, "redemptions": [ ... ] },
"overspent": { "memberId": "member-1", "balance": -2 }
}
The response also carries grabs and bonusResets, as GET does.
Ticking a bonus chore follows its rules. The person must be someone it is open to. An up-for-grabs chore can be finished once each time it comes round, and only by whoever grabbed it, if anyone has. A refusal is a 409 with a reason (not-yours, taken or grabbed) and, for taken and grabbed, the memberId who has it. An everyone-can chore already done this time round is a no-op (changed: false). A bonus chore is also refused (not-today) on a day it does not show up, and a tick for any day of the round someone is holding today is refused while they hold it. A refusal carries completions, grabs and bonusResets as they stand. Finishing an up-for-grabs chore in today's round ends its grab; ticking an earlier round (yesterday's daily chore) leaves today's grab alone; un-ticking one that finished the person's own grab today gives the grab back, unless they now hold as many grabs as the household allows, when it goes back up for grabs. A completion that finished a grab carries endedGrab, the date of that grab, and an un-tick hands the grab back with that same date. Ticking a chore marked "not today" replaces the mark.
changed reports whether this call actually flipped anything, false means the directional request found the chore already in the requested state (and no points moved). rewards is the full updated reward state and is included whenever the toggled chore is worth more than zero points, so the client doesn't have to re-fetch /api/rewards. It is omitted for zero-point chores. overspent is only present in the deficit case described above.
GET /api/chores/today
Returns the resolved per-member chore list for one day, who actually owes what, with rotation (daily/weekly/schedule grids) and frequency rules already applied server-side, plus each chore's completion state. This is what the chore chart renders; use it instead of re-deriving assignments from /api/chores/data. Powers the "what chores does Alice have left?" question in the Voice Control guide. Display access.
| Parameter | Type | Description |
|---|---|---|
date | string | Optional YYYY-MM-DD. Defaults to today (hub-local). Invalid or impossible dates return 400. |
Response:
{
"date": "2026-08-03",
"members": [
{
"id": "member-1",
"name": "Alice",
"chores": [
{ "id": "chore-1", "name": "Make bed", "points": 2, "timeOfDay": "morning", "completed": true }
]
}
]
}
Every member appears, including those with no chores that day (empty chores array). A chore marked "not today" for someone is left out of their list.
Bonus chores are owed by nobody, so they are not in any member's list. They come in their own bonus array: each has id, name, points, kind (up-for-grabs or everyone-can) and openTo (member IDs). An up-for-grabs one has state: { "status": "open" }, { "status": "grabbed", "memberId": ... } or { "status": "done", "memberId": ..., "date": ... }. An everyone-can one has doneBy, the member IDs who have done it this time round.
POST /api/chores/grab
Grabs an up-for-grabs bonus chore for someone, or lets their grab go. Public on the LAN like POST /api/chores, so the kids' page and the wall can grab. A grab is always for the hub's today, whatever day the asking screen thinks it is.
Body: { "choreId": "chore-9", "memberId": "member-2", "action": "grab" } (or "action": "let-go"). A new grab is the only one on its chore.
A grab is refused with 409 and a reason: not-bonus (not an up-for-grabs chore), not-yours, not-today (it does not show up today), taken or grabbed (with the memberId who has it), or limit (the person already holds as many as the household allows; only chores that show up today count). A refusal carries the current lists, like a tick's. Letting go works for anyone's grab. Response: { "completions": [...], "grabs": [...], "bonusResets": {...} }.
POST /api/chores/skip
Marks a chore "not today" for some of the people who have it that day, or takes the mark away. Requires a valid session. Body: { "choreId": "chore-1", "memberIds": ["member-1"], "date": "2026-03-08", "skipped": true }. The date must be within the last 90 days, and everyone listed must have the chore that day (a bonus chore is refused). Someone who already did the chore that day keeps it done. Response: as POST /api/chores/grab.
POST /api/chores/put-back
Opens a done "when I put it back" bonus chore up again. Requires a valid session. Body: { "choreId": "chore-10" }. The completions that paid for it stay; they just stop counting. Response: as POST /api/chores/grab.
PUT /api/chores/settings
Saves the household chore settings. Requires a valid session. Body: { "grabLimit": 1, "grabHold": "day" }. grabLimit is how many up-for-grabs chores one person can hold at once: 1, 2, 3, or 0 for no limit. grabHold is how long an unfinished grab lasts: day (until the end of the day) or until-back (until the chore comes back). Anything else is answered with 400. Response: { "settings": {...} }.
GET /api/chores/data
Returns { "chores": [...], "settings": {...}, "revision": "..." } from data/chores.json. Display access. settings are the household chore settings (see PUT /api/chores/settings); the revision covers the chore list only. A save has to quote the revision back. Each definition contains id, name, emoji, points, frequency fields, assigneeIds, an optional assigneeGroupIds, rotation and, for a scheduled chore, a schedule map of member ID to days plus an optional groupSchedule map of group ID to days. A bonus chore carries bonus: { "claim": "first" | "each", "comesBack": "daily" | "weekly" | "manual" } and is always frequency: "daily" and rotation: "fixed"; a save that breaks that is refused with a 400. A save is refused with a 400 when a groupSchedule row names a group that is not in the chore's assigneeGroupIds. Member IDs and group IDs refer to the members and groups returned by /api/family. GET /api/chores/today already expands groups for you.
PUT /api/chores/data
Replaces the chore definitions. Requires a valid session. Send { "chores": [...], "revision": "..." } with the revision the edit started from; a save without one is answered with 400. An empty replacement of non-empty chore data requires force: true. Payloads containing the former members field are rejected with a refresh-required error; use /api/family for family edits. Assignments must refer to current family members and groups; one that does not is answered with 409. A list built from an older copy is also answered with 409, with reason: "revision" and the current list, so reload and apply the change again.
Response: The saved { "chores": [...], "revision": "..." }, where revision is the one to quote on the next save.
Meals
GET /api/meals/data
Returns saved meals, weekly plan, grocery checked state, and shared meal-planner settings from data/meals.json. Accessible by the display (display token auth).
Response:
{
"savedMeals": [
{ "id": "meal-1", "name": "Tacos", "emoji": "🌮", "tags": ["quick"], "prepTime": 20, "difficulty": "easy" }
],
"plan": [
{ "date": "2026-04-04", "slot": "dinner", "mealId": "meal-1" }
],
"groceryChecked": { "2026-03-30": ["tortillas"] },
"settings": {
"enabledSlots": ["breakfast", "lunch", "dinner"],
"weekStartDay": "monday",
"defaultSlotTimes": { "breakfast": "07:30", "lunch": "12:00", "dinner": "18:00" },
"timeFormat": "12h"
},
"globalTimeFormat": "12h",
"revision": "..."
}
settings.timeFormat is optional, when absent, meal times follow the household GlobalSettings.timeFormat, which the top-level globalTimeFormat field mirrors so clients can resolve "follow global" without a second config fetch. The plan array uses ISO date strings (e.g. "2026-04-04") for multi-week support. Entries older than 12 weeks are pruned on write. groceryChecked holds each week's ticked items separately, keyed by the date that week starts on (per settings.weekStartDay), so an item bought this week is still on next week's list.
PUT /api/meals/data
Partial update, every writable field is optional, and omitted fields are preserved from the existing on-disk data. The request must include at least one of savedMeals, plan, groceryChecked, or settings, or the server returns 400. Requires a valid session.
The entire read-modify-write cycle runs inside the meal-data store queue, so cross-surface writers (editor settings sheet, /remote, grocery checks) cannot interleave and silently lose each other's edits.
Body (all fields optional):
{
"savedMeals": [ ... ],
"plan": [ ... ],
"groceryChecked": { "2026-03-30": [ ... ] },
"settings": { "enabledSlots": ["breakfast", "lunch", "dinner"], "weekStartDay": "monday", "defaultSlotTimes": { "dinner": "18:00" }, "timeFormat": "12h" },
"revision": "...",
"force": false
}
When settings is present it replaces the stored settings object: include "timeFormat": "12h" or "24h" for an explicit override, or omit the key to follow the household GlobalSettings.timeFormat.
When present, savedMeals and plan must be arrays, and groceryChecked must map the date each week starts on (YYYY-MM-DD) to that week's ticked item names; weeks with nothing planned are dropped, and a plan write drops the stored ticks of any week it leaves with nothing planned, so a cleared week starts its list over. When settings changes the week start day, the stored ticks move to the matching new weeks. An empty-overwrite guard fires when every savedMeals / plan field present in the body is [] and the existing data is not empty; the write is refused with 409 and you can resend with force: true to override. If the body sends both fields and only one of them is empty, that is a normal write and the guard stays out of the way. Settings-only and grocery-only writes skip the guard entirely.
A write that sends savedMeals or plan replaces that whole list, so it must also send the revision from the copy it was built from (from GET or the previous save); without one the answer is 400. If the meals or the plan changed since that copy, the answer is 409 with "reason": "revision" and the meals as they are now, so make the change again on top of them and send the new revision. Settings-only and grocery-only writes need no revision and never move it.
Response: the same shape as GET, including the revision to quote on the next save.
GET /api/meals/grocery
Returns one week's grocery checked state. ?week=YYYY-MM-DD picks the week holding that date; without it, the household's current week (the same one /api/meals/grocery/list shows).
Response: { "week": "2026-08-02", "groceryChecked": ["tortillas", "cheese"] }: week is the date that week starts on.
GET /api/meals/grocery/list
Returns the resolved grocery list for the current week, ingredients aggregated from every meal planned this week, grouped by aisle, with each item's checked state. This is what the remote's Grocery tab shows; the aggregation runs server-side with the same generator, so external callers don't have to re-derive it from the meal plan. "Current week" follows the shared week-start meal setting. Powers "what's on the grocery list?" in the Voice Control guide. Display access.
Response:
{
"week": { "start": "2026-08-02", "end": "2026-08-08" },
"categories": [
{ "category": "bakery", "items": [ { "name": "Tortillas", "amount": "12", "checked": false } ] }
],
"total": 4,
"checked": 1
}
POST /api/meals/grocery
Toggles a grocery item's checked state on one week's list. If the item is already checked, it is unchecked; otherwise it is checked. Display access, a display token works, so an automation or voice assistant can check items off. The item name is matched after trimming and lowercasing, so senders can use the display-cased name from /api/meals/grocery/list.
Body:
{
"item": "tortillas",
"direction": "check",
"week": "2026-08-05"
}
week is optional: any date in the week whose list the tick belongs to. Omitted, it is the household's current week, which is what "check off the tortillas" means. Ticks never carry over to another week's list.
direction is optional. Omitted, the call is the historical flip. Set to "check" or "uncheck", the call only ever moves the item in that direction and is a no-op when it's already there, so a repeated voice "check off milk" can never silently un-check it. Any other value is rejected with 400.
Response: { "week": "2026-08-02", "groceryChecked": [...], "changed": true }: that week's ticks after the call. changed reports whether this call actually flipped anything.
Timers
Visual timers and routines, managed from the remote's Timers tab. Routines are family data like meals and chores, they live in data/routines.json, not in the display config. A running timer is a session: a snapshot of the steps plus epoch timestamps, so clients derive the countdown locally and editing a routine mid-run can't corrupt it. There is at most one active session household-wide.
GET /api/timers/routines
Returns the saved routine list. Display access.
Response: { "routines": [ { "id": "...", "name": "...", "icon": "...", "view": "ring", "sound": true, "steps": [ { "id": "...", "label": "...", "icon": "...", "durationSec": 120, "waitForTap": true } ] } ], "revision": "..." }
revision names this copy of the list. A save sends it back.
view is one of ring, face, cascade, or path. A step with waitForTap: true holds at 0:00 with a "Done!" tap target instead of auto-advancing.
PUT /api/timers/routines
Replaces the routine list wholesale (the list is capped at 50, so replace-the-list avoids partial-update merge rules). Validation is all-or-nothing: one bad routine rejects the whole write with 400. An empty list is a legitimate write, it's how the last routine is deleted. Requires a valid session.
Body: { "routines": [ ... ], "revision": "..." }: the list in the same shape as the GET response, and the revision of the copy it was built from (from GET or the previous save). A save with no revision is a 400.
When someone else saved first, the answer is a 409 with "reason": "revision" and the current routines and revision. Make the change again on top of that list and send it with the new revision.
GET /api/timers/session
Returns the active session with elapsed auto-advancing steps already applied, or { "session": null } when nothing is running. Displays fetch this when their 3-second heartbeat says the session changed, and compute the live countdown, step changes and the end locally from the returned timestamps, so request latency only delays the start, it never affects countdown accuracy. Display access.
POST /api/timers/session
Starts a session or controls the running one, selected by action:
start: begin a timer. For a routine:{ "action": "start", "kind": "routine", "routineId": "...", "targets": "all" }. For a quick timer:{ "action": "start", "kind": "quick", "durationSec": 300, "targets": "all" }, with optionalviewandsound.targetsis required and picks which displays the timer takes over:"all"or a non-empty array of display IDs. Starting while a session is already running replaces it, one active session at a time is the intended family-display behavior.pause,resume: pause and resume the countdownskipandstep-done: the same transition (advance to the next step now); both names exist because remotes skip and touch displays tap Doneadd-minute: add a minute to the current stepcancel: stop the session
Display access on purpose, touch kiosks post step-done when a kid taps Done. Every mutation runs through an atomic queue, so overlapping taps from multiple displays can't interleave.
Response: { "session": { ... } } with the updated session.
School timetables
The school week of each child, edited from the timetables window in the editor and drawn by the School Timetable module. Like chores and meals this is family data: it lives in data/timetables.json, not in the display config. One document holds every school's bell schedule, the subject list everyone shares, and one week per person, keyed by the memberId of a person in /api/family. See School timetable.
GET /api/timetables
Returns the whole document and the revision a save has to quote back: { "data": { "schools": [...], "subjects": [...], "timetables": [...] }, "revision": "..." }. Display access, so the wall and the editor read the same thing. A household that has never saved gets an empty document carrying the default subject catalogue for its language; nothing is written until the first real save.
PUT /api/timetables
Replaces the whole document. Requires a valid session. Send { "data": { ... }, "revision": "..." } with the revision the edit started from, capped at 2 MB.
Every member ID is checked against the family roster inside the same locked section as the write, so somebody cannot be removed between the check and the file landing. Validation is all-or-nothing, and the store caps a household at 16 schools, 16 slots per school, 64 subjects, 64 timetables and 64 special days per school.
Response: the saved { "data": ..., "revision": ... }, so the editor reconciles in one step. A save that started from an older copy comes back 409 with the same two fields alongside the error, which is what lets the editor say somebody else changed this and offer to reload it.
GET /api/timetables/holidays
School and public holidays from openholidaysapi.org, for the wall and for the editor's region picker. Display access.
?region=DE-NWanswers with{ "regions": { "DE-NW": { region, year, schoolHolidays, publicHolidays, ok, fetchedAt } } }for the school year running now; add&year=2026for the one starting September 2026. Every row carries its names in every language the source has, so one cached answer serves a household in any language.- Several regions can be asked about at once, repeated (
?region=DE-NW®ion=LU) or comma-separated (?region=DE-NW,LU), because holidays belong to the school and one wall can show schools in several places. The answer is always that map, keyed by the normalized region code, whether one region was asked for or sixteen, so nothing branches on the count. Sixteen is the cap, the most schools one timetables document can hold; more than that comes back400. ?country=DEanswers with{ country, subdivisions, regionCategory, hasSchoolHolidays, fetchedAt }: the regions to choose from, what that country calls one of them in every language the source carries it in (Bundesland,comunidad autónoma), and whether the country has school holidays at all, so the editor can say so instead of showing an empty picker. A region wins when both are asked for.
A failed lookup is not an error here: that region's saved copy from data/school-holidays.json comes back with ok: false and a messageKey the caller phrases, so an offline display keeps showing the right thing. Coverage is partial and it moves, so read it from the answer rather than from a list: as this is written, only Germany, France and the Netherlands have school holiday dates, Spain has subdivisions and no dates, and the United States, Denmark and Brazil have neither. The upstream API answers an unknown country or region with an empty list rather than an error, so every code is validated here before a URL is built.
POST /api/timetables/import/check
Reads a pasted Google Sheets link and previews it. Session required. Send { "url": "https://docs.google.com/spreadsheets/d/..." }.
Nothing is written: applying an import is an ordinary PUT /api/timetables. The reply lists the tabs (up to 16) with the week read out of each one, which subject codes are new, and which family member each tab's name looks like it belongs to, or { "ok": false, "messageKey": "..." } when the sheet cannot be read at all. A tab that will not download is reported on its own rather than failing the whole check.
The server builds every URL it fetches from the ID in the pasted text and addresses a tab by gid, never by name, so a link cannot send the hub anywhere else and a renamed tab cannot quietly import the wrong child's week.
Backups
Take and restore a full household backup, the same thing Settings > Backups & data does, and read or clear the backup reminder.
GET/POST /api/backup
Full household backup bundle, exports config, family, chores, choreCompletions, meals, rewards, routines, todos, and timetables as a single JSON file with a _type: "home-screens-backup" envelope and a _version marker (currently 2). POST accepts the same shape (plus a legacy config-only format) to restore everything at once. Session required.
GET never returns credentials or icon pictures. A bundle can carry an optional credentials section, but only POST /api/backup/credentials produces one. It can also carry an optional customIcons section, the pictures the family added as icons: the editor and the phone fetch it from GET /api/backup/custom-icons (session required; ?summary=1 answers only { "count": ..., "bytes": ... }) and add it when Include your icons is on. Restoring a bundle without it keeps the icons already on the hub, and anything pointing at an icon the hub does not have shows a standard icon. This is what Settings > Backups & data > Save a copy uses, and it is distinct from the upgrade-time config-only snapshots under /api/system/backups.
To restore a bundle whose credentials section is encrypted, add a transient _passphrase field to the POST body (it is stripped before anything is written and never stored). Credential failures come back as machine codes so the editor can localize them: 400 { "error": "passphrase_required" } when the section is locked and no password was sent, 400 { "error": "bad_passphrase" } when it was wrong, and 400 { "error": "invalid_credentials" } when the section is damaged. None of these write anything, dropping the credentials field and re-posting restores everything else.
A restore body is capped at 100 MB, room for a full icon library. The config inside it is checked for shape and for a valid display registry before anything is written, and if a later part of the bundle fails partway through, the parts that already landed are put back the way they were, so a failed restore doesn't leave a mix of old and new data.
POST response: { "restored": { "config": true, "chores": true, "choreCompletions": true, "meals": false, "rewards": false } }, one flag per section, including family and customIcons, true for the ones the bundle actually contained. missingIcons counts the icons the restored data points at that this hub does not have, which happens only when the bundle was made without its icons. A modern bundle replaces the roster when family is present. Older bundles without it retain the current roster and fold legacy chore and calendar identities into it by ID first. When the bundle carried credentials, a credentials: { applied: [...], skipped: [...] } object is included too, applied naming the sections written, skipped naming anything deliberately held back (for example auth.ipRestrictAccess, when restoring it would lock the requesting device out). A body that is neither a backup bundle nor a bare configuration returns 400 { "error": "Unrecognized backup format" }.
POST /api/backup/credentials
Builds the optional credential section of a backup bundle: data/secrets.json, iCloud app passwords, the Google Calendar / Google Photos / OneDrive grants, per-plugin secrets and OAuth tokens, and data/auth.json. The response is Cache-Control: no-store.
Requires an editor password to be set, not merely a session: requireSession is a no-op on an install with no password, and this is the only route that returns raw secret values. With no password configured it returns 403 { "error": "editor_password_required" } and logs a credential_backup_denied audit entry.
Body: { "passphrase": "…" } to seal the section (scrypt + AES-256-GCM, minimum 8 characters), or {} for a plaintext one. A password shorter than the minimum returns 400 { "error": "passphrase_too_short" }.
Response: { "encrypted": false, "data": { … } }, or { "encrypted": true, "kdf": "scrypt", "kdfParams": {…}, "salt": "…", "iv": "…", "tag": "…", "ciphertext": "…" }. Sections this device has nothing for are omitted. The caller merges the result into a bundle from GET /api/backup as its credentials field.
GET /api/backup/reminder
Returns when you last took a backup and when you last dismissed the reminder about it, which is how the editor decides whether to nudge you. Display access.
Response: { "lastBackupDate": "2026-03-08T12:00:00.000Z", "lastDismissedDate": null }
Both fields are null until the corresponding thing has happened.
POST /api/backup/reminder
Updates that state. Display access.
Body: { "action": "dismiss" } to put the reminder off, or { "action": "backed-up" } to record that a backup was taken (which also clears the dismissal). Any other action returns 400 { "error": "Invalid action" }.
Response: the updated state, the same shape the GET returns.
GET /api/system/backups
Lists available configuration backups. Requires a valid session.
Response:
{
"backups": [
{ "name": "config-v0.9.0-20260308-120000.json", "size": 4096, "date": "2026-03-08T12:00:00Z" }
]
}
Pass ?download=config-v0.9.0-20260308-120000.json to download a specific backup file.
POST /api/system/backups
Restores a configuration backup. Requires a valid session.
Body: { "name": "config-v0.9.0-20260308-120000.json" }
Response: { "ok": true }
All endpoints
Every route the hub serves, generated from the route files, with the access each method needs:
- Open: nothing, even once a password is set.
- Session: a signed-in browser (the
hs-sessioncookie). - Display: a session or the display token (see Access).
- Media: display access, or a signed link to one photo or video.
- Adopted display: no token, but the
displayID has to be one the hub has adopted (see Display-only Pis).
With no password set, only the adopted-display check applies.
| Endpoint | Methods | Access |
|---|---|---|
/api/air-quality | GET | Display |
/api/auth/display-token | GET, POST | Session |
/api/auth/google/apps | GET | Session |
/api/auth/google/device | POST, PUT | Session |
/api/auth/google/status | GET, DELETE | Session |
/api/auth/ip-allowlist | GET, PUT | Session |
/api/auth/login | POST | Open |
/api/auth/logout | POST | Open |
/api/auth/password | POST | Session |
/api/auth/revoke-sessions | POST | Session |
/api/auth/status | GET | Open |
/api/backgrounds | GET, POST, DELETE | GET display, POST session, DELETE session |
/api/backgrounds/directories | GET, POST, PATCH, DELETE | Session |
/api/backgrounds/inventory | GET | Session |
/api/backgrounds/move | POST | Session |
/api/backgrounds/rotate | GET | Display |
/api/backgrounds/serve | GET | Media |
/api/backup | GET, POST | Session |
/api/backup/credentials | POST | Session |
/api/backup/custom-icons | GET | Session |
/api/backup/reminder | GET, POST | Display |
/api/calendar | GET | Display |
/api/calendar/check | POST | Session |
/api/calendar/status | GET | Display |
/api/calendars | GET | Session |
/api/chores | GET, POST | Open |
/api/chores/data | GET, PUT | GET display, PUT session |
/api/chores/grab | POST | Open |
/api/chores/put-back | POST | Session |
/api/chores/settings | PUT | Session |
/api/chores/skip | POST | Session |
/api/chores/today | GET | Display |
/api/config | GET, PUT | GET display, PUT session |
/api/crypto | GET | Display |
/api/custom-icons | GET, POST | GET display, POST session |
/api/custom-icons/{id} | PATCH, DELETE | Session |
/api/custom-icons/serve | GET | Media |
/api/display/{action} | GET, POST | Display |
/api/display/console-log | POST | Display |
/api/display/hw-stats | POST | Adopted display |
/api/display/kiosk-bootstrap | GET | Adopted display |
/api/display/kiosk-bundle | GET | Adopted display |
/api/display/power-state | GET | Adopted display |
/api/displays | GET | Display |
/api/family | GET, PUT | GET display, PUT session |
/api/family/groups | PUT | Session |
/api/geocode | GET | Session |
/api/google-picker/auth | GET, POST | Session |
/api/google-picker/import | GET, POST | Session |
/api/google-picker/session | GET, POST, DELETE | Session |
/api/google-picker/status | GET, DELETE | Session |
/api/health | GET | Open |
/api/history | GET | Display |
/api/holidays | GET | Display |
/api/i18n/{locale} | GET | Open |
/api/icloud/accounts | GET, POST, DELETE | Session |
/api/icloud/calendars | GET | Session |
/api/icloud/import | GET, POST | Session |
/api/icloud/photos | GET | Display |
/api/image-proxy | GET | Display |
/api/immich/albums | GET | Session |
/api/immich/people | GET | Session |
/api/immich/photos | GET | Display |
/api/immich/serve | GET | Display |
/api/immich/validate | GET | Session |
/api/immich/video | GET | Media |
/api/jokes | GET | Display |
/api/meals/data | GET, PUT | GET display, PUT session |
/api/meals/grocery | GET, POST | Display |
/api/meals/grocery/list | GET | Display |
/api/nasa | GET, POST | Session |
/api/nasa/asset | GET | Session |
/api/news | GET | Display |
/api/onedrive/auth | GET, POST, DELETE | Session |
/api/onedrive/folders | GET | Session |
/api/onedrive/photos | GET | Display |
/api/onedrive/serve | GET | Display |
/api/onedrive/status | GET | Session |
/api/plugins/asset/{pluginId}/{path...} | GET | Display |
/api/plugins/auth/{pluginId}/disconnect | DELETE | Session |
/api/plugins/auth/{pluginId}/poll | PUT | Session |
/api/plugins/auth/{pluginId}/start | POST | Session |
/api/plugins/auth/{pluginId}/status | GET | Display |
/api/plugins/auth/callback | GET | Open |
/api/plugins/bundle/{pluginId} | GET | Display |
/api/plugins/dev | POST | Session |
/api/plugins/install | POST, PATCH, DELETE | Session |
/api/plugins/install-external | POST | Session |
/api/plugins/installed | GET | Display |
/api/plugins/manifest/{pluginId} | GET | Display |
/api/plugins/migrate-config | POST | Session |
/api/plugins/proxy/{pluginId} | POST | Display |
/api/plugins/registry | GET | Open |
/api/plugins/secrets/{pluginId} | GET, PUT, DELETE | Session |
/api/plugins/settings/{pluginId} | GET, PUT | Session |
/api/quote | GET | Display |
/api/rain-map | GET | Display |
/api/rewards | GET, POST | Open |
/api/rewards/data | POST, PUT | Session |
/api/secrets | GET, PUT, DELETE | Session |
/api/sports | GET | Display |
/api/sports/team | GET | Display |
/api/sports/teams | GET | Session |
/api/standings | GET | Display |
/api/stocks | GET | Display |
/api/system/address | GET | Display |
/api/system/backups | GET, POST | Session |
/api/system/build-id | GET | Open |
/api/system/changelog | GET | Session |
/api/system/diagnostics | GET | Session |
/api/system/network | GET | Session |
/api/system/network/confirm | GET, POST | Session |
/api/system/network/diagnostics | GET | Session |
/api/system/network/hostname | PUT | Session |
/api/system/network/ip | PUT | Session |
/api/system/network/wifi/connect | POST | Session |
/api/system/network/wifi/disconnect | POST | Session |
/api/system/network/wifi/saved | GET, DELETE | Session |
/api/system/network/wifi/scan | GET | Session |
/api/system/power | POST | Session |
/api/system/rollback | POST | Session |
/api/system/stats | GET | Session |
/api/system/status | GET | Session |
/api/system/sudo-grant | POST | Session |
/api/system/timezone | PUT | Session |
/api/system/update-notification | GET, POST | Display |
/api/system/upgrade | POST, DELETE | Session |
/api/system/version | GET | Session |
/api/time | GET | Display |
/api/timers/routines | GET, PUT | GET display, PUT session |
/api/timers/session | GET, POST | Display |
/api/timetables | GET, PUT | GET display, PUT session |
/api/timetables/holidays | GET | Display |
/api/timetables/import/check | POST | Session |
/api/timetables/import/file | POST | Session |
/api/timetables/sync | POST | Session |
/api/todo/lists | GET, POST | GET display, POST session |
/api/todo/lists/{listId} | PATCH, DELETE | Session |
/api/todo/lists/{listId}/items | POST | Session |
/api/todo/lists/{listId}/items/{itemId} | PATCH, DELETE | PATCH display, DELETE session |
/api/todoist | GET, PUT | GET display, PUT session |
/api/todoist/close | POST | Display |
/api/traffic | GET | Display |
/api/unsplash | GET, POST | Session |
/api/weather | GET | Display |
/api/weather/check-key | POST | Session |