Developers
API reference
On this page
This page lists every HTTP endpoint the server offers: what it is for, who may call it, what to send, what comes back, and the status codes and limits. It is for developers who write a client (the kiosk, a script, a test) or a widget route. All examples use https://example.com/frames/ as the site address, which is the frames_url in config.php.
Everything on this page was taken from the routes in web/app/routes/ and web/app/widgets/*/routes.php and admin_routes.php. If you add a route, add it here.
Conventions
Four kinds of caller
| Caller | Base address | How it proves who it is |
|---|---|---|
| A frame (the kiosk app or its WebView) | /frames/api/v1/… and /frames/… |
Its frame key: the header Authorization: Frame <id>.<key>, or the fk cookie |
| The admin | /frames/admin/api/… |
A signed-in session cookie, plus a CSRF token on writes |
| A person with a link | /frames/drop/<token>, /frames/notes/<token>, /frames/api/v1/drops/<token>/…, /frames/api/v1/notes/<token>/… |
A 43-character secret in the address |
| Anyone | POST /frames/api/v1/frames |
Nothing (enrollment), but it is rate limited |
The frame-facing API is versioned (/api/v1/). Shipped kiosks cannot be updated in one step, so version 1 changes only by adding: new endpoints, new response keys, new optional request fields. Clients must ignore keys they do not know. The admin API is unversioned and moves with the admin page.
Requests and responses
- JSON bodies need
Content-Type: application/json(else415) and must be a JSON object. A field the endpoint does not list is a422. Each endpoint caps the body (listed below). - Raw bodies (photos, APKs, screenshots) need an exact or
image/*content type, as listed per endpoint. - Responses are
application/json; charset=utf-8withCache-Control: no-store, except images, files and assets, which say otherwise. - A
201carries aLocationheader. A405carriesAllow. AHEADrequest is treated as aGET. - Timestamps are Unix seconds. Frame ids are 32 lowercase hex characters; keys are 64.
Errors
Every error has this shape. The client should branch on code, never on the text.
{ "error": "human readable message", "code": "stable_code" }
A validation failure (422) adds the offending fields:
{ "error": "validation failed", "code": "invalid", "fields": { "name": "name must be 1-64 characters" } }
| Status | Default code | Meaning |
|---|---|---|
400 |
bad_request |
The body was not a JSON object |
401 |
unauthorized |
No or wrong credentials. Also pairing_expired (a frame's code ran out) and auth_required (admin: sign in) |
403 |
forbidden |
Authenticated but not allowed. See the specific codes below |
404 |
not_found |
No such route or record. Also used to hide records the caller may not know about |
405 |
method_not_allowed |
The path exists, the method does not. Allow lists the methods |
409 |
conflict |
The request conflicts with the current state. Always has a specific code where the caller can act on it |
413 |
too_large |
Body over the endpoint's limit |
415 |
unsupported_media_type |
Wrong Content-Type, or a file that is not an accepted type |
422 |
invalid |
A field failed validation; see fields |
429 |
rate_limited |
Too many requests. Retry-After says how many seconds to wait |
500 |
internal |
A server error. Details go to the log, never to the client. not_configured means config.php is missing or invalid |
503 |
— | maintenance (the database is at a different schema version than the code; Retry-After: 30; run bin/migrate.php), capacity, busy, unavailable |
Specific 403 codes: id_mismatch (a frame asked for another frame's data), not_active (the frame is not claimed), revoked (body also has "status": "revoked"), header_required, bad_origin, bad_csrf, reauth_required, expired.
Rate limits
| Limit | Value | Applies to |
|---|---|---|
| Enrollment | 3 per hour per address; 3 unclaimed frames per address; 50 unclaimed in total | POST /api/v1/frames |
| Kiosk release download | 12 per hour per frame | GET …/kiosk-releases/{sha256} |
| Screenshot requests | One every 10 seconds per frame | Admin screenshot request |
| Pairing code guesses | 5 failures per 10 minutes (all addresses together) | Admin claim and pairing lookup |
| Admin sign-in failures | 10 per 15 minutes per address; 20 per hour per user | Login and re-authentication |
| Drop link, requests | 120 per 10 minutes per link | Any drop call |
| Drop link, unknown link | 20 per 10 minutes per address | Wrong links |
| Drop link, uploads | 100 per hour and 300 per day per link; 500 per day per address; 10 rejected files per hour per link | Drop upload |
| Card link, requests | 120 per 10 minutes per sender | Any card-link call |
| Card link, unknown link | 20 per 10 minutes per address | Wrong links |
| Card link, sending | 6 per hour and 20 per day per sender; 30 per day per address | POST …/cards |
IPv6 addresses are counted by their /64. The drop and card limits can be changed for photos under widgets.photos in config.php.
Body size limits
Apache enforces the first number; the app enforces the exact value.
| Endpoint | Limit |
|---|---|
| Default, frame site | 64 KiB (enrollment: 1 KiB in the app; telemetry: 16 KiB) |
| Screenshot upload | 600 KiB (614,400 bytes) |
| Drop photo upload | 30 MiB |
| Default, admin | 16 KiB |
| Photo upload (admin) | 30 MiB |
| Card art upload (admin) | 4 MiB |
| Kiosk release upload | 16 MiB |
How a frame joins
sequenceDiagram
participant K as Kiosk
participant S as Server
participant A as Admin
K->>S: POST /api/v1/frames {id, key, app_version}
S-->>K: 201 {status: unclaimed, pairing_code, pairing_expires_in}
loop every 10 s while unclaimed
K->>S: GET /api/v1/frames/{id}
S-->>K: 200 {status: unclaimed, pairing_code}
end
A->>S: GET /admin/api/frames?pairing_code=123456
S-->>A: 200 [the unclaimed frame, enrolled_at, enrolled_ip]
A->>S: PATCH /admin/api/frames/{id} {status: active, pairing_code, name}
S-->>A: 200 frame
K->>S: GET /api/v1/frames/{id}
S-->>K: 200 {status: active, config_version, display, ...}
loop every poll_seconds
K->>S: GET /api/v1/frames/{id}
K->>S: PUT /api/v1/frames/{id}/telemetry
end
Frame API
All paths are under /frames/api/v1. "Frame auth" means the Authorization: Frame <id>.<key> header or the fk cookie; a request for another frame's id gets 403 id_mismatch.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/frames |
none | Enroll |
GET |
/frames/{id} |
frame | Status and configuration ("self GET") |
PUT |
/frames/{id}/telemetry |
frame, active | Report health |
PUT |
/frames/{id}/screenshot |
frame, active | Upload a requested screenshot |
GET |
/frames/{id}/dashboard |
frame, active | The dashboard JSON the page draws |
GET |
/frames/{id}/kiosk-releases/{sha256} |
frame header, active | Download the kiosk update |
GET |
/frames/{id}/photos/{photoId}.jpg |
frame, active | A photo from the local library |
GET, PATCH |
/frames/{id}/notes/{cardId} |
frame, active | Read a card; mark it opened |
POST /frames enroll
Body (at most 1 KiB):
| Field | Type | Rule |
|---|---|---|
id |
string | 32 lowercase hex characters, chosen by the frame |
key |
string | 64 lowercase hex characters, chosen by the frame |
app_version |
integer | 0 to 2,147,483,647 |
| Status | Code | Body |
|---|---|---|
201 |
{id, status: "unclaimed", pairing_code, pairing_expires_at, pairing_expires_in}, with Location |
|
200 |
The same body: a retry with the same id and key while still unclaimed | |
409 |
already_enrolled |
Same key, but the frame is no longer unclaimed. Use the self GET |
409 |
id_conflict |
The id belongs to a different key. Make a new identity |
422 |
invalid |
fields names id, key or app_version |
429 |
rate_limited |
Retry-After |
503 |
capacity |
Too many unclaimed frames, or no free pairing code. Retry-After |
The pairing code is six digits and is valid for 24 hours. pairing_expires_in counts seconds from the response, so the kiosk needs no synchronized clock.
GET /frames/{id} self GET
The answer depends on the frame's status.
| Status | Condition | Body |
|---|---|---|
200 |
Unclaimed | {status: "unclaimed", pairing_code, pairing_expires_at, pairing_expires_in} |
401 |
pairing_expired |
The code ran out. Enroll again with the same id and key |
403 |
revoked |
{error, code: "revoked", status: "revoked"} |
200 |
Active | The object below |
Active body:
| Key | Type | Notes |
|---|---|---|
status |
string | "active" |
name |
string | The frame's name |
config_version |
integer | Rises whenever the admin changes something that affects this frame |
display |
object | {kind: "dashboard" or "url", url}. For dashboard the URL is the frame site |
poll_seconds |
integer | 20 |
idle_home_seconds |
integer | 30 to 3600 |
allowed_origins |
list of strings | The Home Assistant origin when that widget is on; otherwise [] |
tz |
string | IANA time zone |
schedule |
object or null | {bedtime: "HH:MM", wake_time: "HH:MM"} in tz, or null when always on |
photos |
list of strings, or absent | The frame's daily photo set (at most 50 URLs). [] means "clear your cache". The key is absent when the server could not work it out, which means "keep your cache" |
screenshot_request |
string | 32 hex characters, present only while a live-view request is pending and under 60 seconds old |
pin |
object or null | Header auth only. {alg: "pbkdf2-sha1", iter, salt, hash}. null means "use the build's default PIN" |
widget_secrets |
object | Header auth only. {<widget id>: {…}}, for example {"ha": {"token": "…"}} |
kiosk_update |
object | Header auth only. Present when the frame has a target release it does not yet run. See the update protocol |
The three header-only keys are absent, not empty, on a cookie request, because page script must never see them.
PUT /frames/{id}/telemetry
A PUT replaces the stored report: an omitted optional field is stored as empty. The one exception is ha_dashboards. Body limit 16 KiB. Answer 204.
| Field | Type | Rule |
|---|---|---|
app_version |
integer | Required. 0 to 2,147,483,647 |
local_ip |
string | A valid IP address |
mac |
string | aa:bb:cc:dd:ee:ff |
ssid |
string | At most 32 bytes, no control characters |
rssi |
integer | -127 to 0 |
uptime |
integer | Seconds, 0 to 315,360,000 |
version_code |
integer | The installed kiosk build, 1 to 2,100,000,000 |
watchdog |
boolean | Whether the rollback watchdog can work on this frame |
kiosk_update_failure |
object | {sha256, reason}. reason is download, size, hash, not_apk, package, not_newer, version_mismatch, cert, release_sig, unhealthy, not_owner, or install_<status>. A well-formed report about a release that is not the frame's current target is dropped without an error; a malformed one is a 422 |
ha_dashboards |
list |
Up to 50 {path, title} (see below).
Omitted or null leaves the stored list alone
; [] clears it
|
ha_dashboards entries: path matches ^[a-z0-9][a-z0-9_-]{0,63}$; title is at most 80 characters, and one with control or format characters is replaced by its path. Any bad entry is a 422 with fields.ha_dashboards.
PUT /frames/{id}/screenshot
The admin asks for a live view; the self GET delivers a request id; the kiosk captures its screen and uploads it here.
Query: request_id (32 hex), dimmed (0 or 1), age_ms (0 to 60,000). Body: the raw JPEG, Content-Type: image/jpeg, at most 600 KiB, between 1 × 1 and 1920 × 1200 pixels.
| Status | Code | Meaning |
|---|---|---|
204 |
Stored, and the request id is spent. A repeat of an upload that already landed is also 204 |
|
409 |
no_request |
The id does not match, was already fulfilled, or is older than 90 seconds |
413 |
too_large |
Over 600 KiB |
415 |
Wrong content type | |
422 |
invalid |
fields names request_id, dimmed, age_ms or body |
The capture time is the server's time minus age_ms; the frame's clock is not trusted.
GET /frames/{id}/dashboard
Returns what the page draws. Cache-Control: no-store.
{
"now": 1790000000,
"v": "1789999000:12",
"tz": "America/Chicago",
"idle_home_seconds": 120,
"pages": [ { "widget": "weather", "label": "Weather", "icon": "weather" } ],
"widgets": { "clock": {}, "weather": { "place": "Home", "now": {}, "hourly": [], "daily": [] } }
}
vis<newest app file modification time>:<config_version>. When it changes, the page reloads itself.pageslists the rail items the frame should show.widgetshas an entry for each enabled widget and none for the others. A widget with nothing to say is{}.
What each widget puts in its entry:
| Widget | Entry |
|---|---|
clock |
{} |
weather |
{place, now: {temp, text, icon, wind, humidity, feels_like} or null, hourly: [{at, temp, icon, pop}], daily: [{date, hi, lo, icon, pop}]} (24 hourly, 7 daily entries at most) |
calendar |
{today: [event] or null, week: {"YYYY-MM-DD": [event]}}. today: null means no calendar is connected yet. An event is {title, all_day, start, end, location, calendar} |
photos |
{seconds, photos: [url]} |
ha |
{url} |
notes |
{keep_days, unopened_total, unopened: [card summary], mailbox: [card summary]}. A summary has id, from, sent_at, stamp, cover (and opened_at in the mailbox). No message text |
GET /frames/{id}/kiosk-releases/{sha256}
Downloads the kiosk update. Header auth only: a cookie request gets 403 header_required. The sha256 (64 lowercase hex) must be the hash of this frame's current target release, or the answer is 404. The file is chosen from the database row, never from the URL.
| Status | Meaning |
|---|---|
200 |
The APK. Content-Type: application/vnd.android.package-archive, Content-Length, Content-Disposition: attachment, X-Content-Type-Options: nosniff, Cache-Control: private, no-store, Accept-Ranges: none. Records that this frame fetched the release in full |
403 |
header_required, not_active, revoked |
404 |
Not this frame's target, or the file is missing |
429 |
12 downloads per hour. Retry-After |
GET /frames/{id}/photos/{photoId}.jpg
Returns one frame-sized photo from the local library. photoId is a positive integer. Auth is frame header or cookie.
200image/jpegwith a strongETag(the content hash).If-None-Matchgets304. AlsoX-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; sandbox,Cross-Origin-Resource-Policy: same-origin,Cache-Control: private, max-age=86400.404unless the photos widget is on for this frame, its source is the local library, and the photo is in the frame's current approved catalog.
GET, PATCH /frames/{id}/notes/{cardId}
Part of the cards widget.
GETreturns the full card:{id, from, sent_at, opened_at, cover, stamp, message, signature, signature_text}.404if the card is another frame's, cards are off, or it has aged out of the mailbox.PATCHwith{"opened": true}(the only accepted body) marks it opened and returns{opened_at}, the server's time.
Page, assets and widget scripts
Frame auth, active frames only. These are what the WebView loads.
| Method | Path | Returns |
|---|---|---|
GET |
/frames/ |
The dashboard HTML (Cache-Control: no-store, strict CSP, Referrer-Policy: no-referrer) |
GET |
/frames/assets/frame.js, /frames/assets/frame.css |
The core script, and the CSS bundle (core plus every widget's frame/*.css) |
GET |
/frames/assets/w/{widgetId}/widget.js |
A widget's script. Only registered ids; the file name is fixed |
Assets carry an ETag and answer 304. With a valid ?v= query they are cached for a year (private, max-age=31536000, immutable); without one, no-cache.
Admin API
All paths are under /frames/admin/api. Rules for every endpoint:
- A signed-in session cookie is required, else
401 auth_requiredwithWWW-Authenticate: Cookie realm="frame-admin". - The request must be same-origin (
Sec-Fetch-Site: same-origin, or a matchinghttpsOrigin), reads included, else403 bad_origin. - Anything but a
GETneeds the headerX-CSRF-Token(fromGET /api/session), else403 bad_csrf. - JSON bodies are at most 16 KiB.
- The "Re-auth" column marks endpoints that need the password entered in the last five minutes. They fail with
403 reauth_requireduntilPOST /api/session/reauthenticationssucceeds.
Every Location header is built from frames_url.
Session
| Method | Path | Re-auth | Purpose | Answer |
|---|---|---|---|---|
GET |
/session |
Who am I; get the CSRF token | 200 {authenticated: true, user, csrf} |
|
DELETE |
/session |
Sign out | 204 |
|
POST |
/session/reauthentications |
Prove the password again. Body {password, code} (code only if two-step sign-in is on) |
204; 401 bad_credentials; 429 |
Frames
| Method | Path | Re-auth | Purpose |
|---|---|---|---|
GET |
/frames |
List every frame. With ?pairing_code=123456, list only the matching unclaimed frame |
|
PATCH |
/frames/{id} |
Sometimes | Claim, revoke, restore or edit a frame |
DELETE |
/frames/{id} |
Yes | Delete a frame. It gets 401 and may enroll again |
DELETE |
/frames?status=unclaimed |
Yes | Delete every unclaimed frame |
PUT |
/frames/{id}/pin |
Yes | Set the frame's PIN. Body {pin}: 4 to 8 digits. Answer 204 |
DELETE |
/frames/{id}/pin |
Yes | Clear it. The kiosk falls back to its build default |
POST |
/frames/{id}/screenshot-requests |
Ask for a live view | |
GET |
/frames/{id}/screenshot |
Status of the request and the latest capture | |
GET |
/frames/{id}/screenshot/image?v= |
The latest capture as image/jpeg |
|
DELETE |
/frames/{id}/kiosk-update-failure |
Yes | Let the frame try its current target again |
PATCH /frames/{id}
accepts any of these fields (others are a 422):
| Field | Rule |
|---|---|
status |
active or revoked. An unclaimed frame can only become active, and then needs pairing_code and name |
pairing_code |
Six digits. Checked again inside the transaction. Only used when claiming |
name |
1 to 64 characters, no control characters |
display_kind |
dashboard or url |
display_url |
An https address with no credentials, at most 2048 characters. Required when display_kind is url |
layout |
dashboard |
tz |
An IANA time zone |
idle_home_seconds |
30 to 3600 |
pick_time |
HH:MM frame-local. Claiming assigns the first free minute from 05:00 spiralling out (05:00, 05:02, 04:58, 05:04, …) unless you send one |
bedtime, wake_time |
HH:MM or null. Both set or both null, and different |
kiosk_target_id |
A release id, or null to clear |
Re-auth is required when the request revokes or restores a frame, changes display_kind or display_url, or includes kiosk_target_id. The answer is 200 with the frame (below). Specific errors: 409 invalid_transition, 409 pairing_failed (wrong or expired code; counts toward the 5-per-10-minutes limit), 409 pick_time_unavailable (another active frame picks at the exact same UTC minute), 409 not_active, 409 release_not_proven, 422.
A frame object:
{
"id": "0123456789abcdef0123456789abcdef", "short_id": "01234567", "name": "Kitchen", "status": "active",
"online": true, "last_seen": 1790000000, "last_ip": "192.0.2.10",
"app_version": 10, "local_ip": "192.0.2.20", "mac": "02:00:00:00:00:01", "ssid": "home", "rssi": -52, "uptime": 86400,
"layout": "dashboard", "location": "Home", "display_kind": "dashboard", "display_url": null,
"tz": "America/Chicago", "idle_home_seconds": 120, "pick_time": "05:00", "bedtime": null, "wake_time": null,
"config_version": 12, "enrolled_at": 1780000000, "enrolled_ip": "192.0.2.10", "pin_set": true,
"kiosk": { "version_code": 14, "watchdog": true, "target": null, "status": "none", "failure": null },
"screenshot": null, "screenshot_supported": true
}
online means seen in the last 120 seconds. kiosk.status is none (no target), unknown (no report yet), current, pending or failed. kiosk.target is {id, version_code, version_name}; kiosk.failure is {sha256, reason} and only shows when it is about the current target.
POST /frames/{id}/screenshot-requests
answers 202 {request: {id, requested_at, expires_at}} with a Location to the status. A second call while one is pending returns the same body. Errors: 404, 409 not_active, 409 unsupported (the kiosk's app_version is below 9), 429 with Retry-After (under 10 seconds since the last request). Poll GET …/screenshot until latest.request_id equals the id you got; if request becomes null without a match, it expired.
GET /frames/{id}/screenshot
answers {request: {id, requested_at, expires_at} or null, latest: {request_id, captured_at, uploaded_at, dimmed, width, height, bytes, image_url} or null}.
Library: locations and calendars
| Method | Path | Purpose |
|---|---|---|
GET, POST |
/locations |
List, create. Body {name, lat, lon, stations}. name 1 to 64 characters, lat within ±90, lon within ±180, stations optional: comma-separated 4-character station ids, at most five |
PATCH, DELETE |
/locations/{id} |
Edit (any fields), delete. 409 in_use while a widget uses it |
GET, POST |
/calendars |
List, create. Body {label, url, color}. url is https, on the calendar_hosts list, no credentials or port; color is #rrggbb or empty |
PATCH, DELETE |
/calendars/{id} |
Edit, delete. 409 in_use while a widget uses it |
Creating returns 201 with a Location. A duplicate name or address is 409. A calendar is returned as {id, label, host, color, created_at, state}; the full address is never returned. state is {last_ok, last_err, fail_count, next_at} or null.
Widgets on a frame
| Method | Path | Purpose |
|---|---|---|
GET |
/frames/{id}/widgets |
Every registered widget with this frame's settings |
PUT |
/frames/{id}/widgets/{widget} |
Set a widget's switch, position and settings |
GET returns a list. Enabled widgets come first, ordered by when they were switched on, then the rest in registry order. Each row:
{
"widget": "weather", "label": "Weather", "icon": "w-weather", "description": "Current conditions and forecast for a location.",
"settings": { "location_id": "location" }, "enabled": true, "position": 1, "enabled_at": 1780000000,
"config": { "location_id": 1 }, "info": {}
}
settings maps each setting key to its type. config omits secret settings. info appears only for widgets that have an info hook.
PUT body: {enabled: bool, position: 0 to 99, config: {…}}. Answer 204. 404 for an unknown widget id; 422 with fields.config (a setting failed), fields.enabled or fields.position. A secret setting you leave out is kept; "" clears it. Any real change bumps the frame's config_version.
Kiosk releases
| Method | Path | Re-auth | Purpose |
|---|---|---|---|
GET |
/kiosk-releases |
List, newest first | |
POST |
/kiosk-releases |
Yes | Upload a release |
PATCH |
/kiosk-releases/{id} |
Yes | Change the notes. Body {notes}: at most 500 characters |
DELETE |
/kiosk-releases/{id} |
Yes | Delete the record and the file |
POST |
/kiosk-releases/{id}/rollouts |
Yes | Point every active frame below this version at it |
A release object: {id, version_code, version_name, sha256, size, notes, uploaded_at, uploaded_by, running_on, targeted, proven}. running_on counts frames reporting that version, targeted counts frames told to install it, proven is whether a frame has downloaded it and then reported running it.
POST /kiosk-releases
: the body is the raw APK. Headers: Content-Type: application/vnd.android.package-archive and X-Release-Signature: <base64 DER ECDSA signature>. At most 16 MiB.
| Status | Code | Meaning |
|---|---|---|
201 |
The release object, with Location |
|
409 |
duplicate_sha256, duplicate_version_code |
|
413 |
too_large |
Over 16 MiB |
415 |
not_apk, or wrong content type |
The file is not a plain APK (the same check the kiosk makes) |
422 |
invalid |
fields.signature (malformed, or does not verify against kiosk_release_pubkey when that is set) or fields.body (empty, no readable manifest, package is not com.calevans.frame, version code out of range, version name unusable) |
POST /kiosk-releases/{id}/rollouts
answers 200 {targeted: [frame ids], already: [frame ids]}, or 409 release_not_proven if no frame has proven the release. DELETE answers 204, or 409 release_targeted while a frame still targets it.
Setting a single frame's target is PATCH /frames/{id} with kiosk_target_id. A frame without the rollback watchdog may only be given a proven release (409 release_not_proven).
The same upload exists on the command line: php app/bin/kiosk-release.php add <apk> [<sig>] [--notes "…"].
Photo library (photos widget)
Where an endpoint takes or returns a photo, the item is:
{
"id": 41, "album_id": 3, "status": "pending", "width": 1280, "height": 720, "bytes": 183422,
"added_at": 1790000000, "approved_at": null, "added_by": "Aunt Sue", "drop_link_id": 7,
"thumb_url": "/frames/admin/api/photos/41/thumb.jpg", "image_url": "/frames/admin/api/photos/41/image.jpg"
}
status is pending or approved; approved_at is null while pending. added_by is "admin" for admin uploads and otherwise the link's name when the photo arrived. width, height and bytes describe the stored, resized JPEG.
| Method | Path | Re-auth | Purpose |
|---|---|---|---|
GET |
/photo-library |
Limits, usage and decoder status | |
GET, POST |
/photo-albums |
List ({items}), create ({name}: 1 to 60 characters) |
|
PATCH |
/photo-albums/{a} |
Rename | |
DELETE |
/photo-albums/{a} |
Yes | Delete with its photos and links. 409 in_use while a frame is set to it |
GET |
/photo-albums/{a}/photos |
List photos | |
POST |
/photo-albums/{a}/photos |
Upload one photo | |
POST |
/photo-albums/{a}/photo-deletions |
Yes | Delete up to 500 photos (also how a pending batch is rejected) |
POST |
/photo-albums/{a}/photo-approvals |
Approve up to 500 pending photos | |
GET |
/photos/{p} |
One photo item | |
DELETE |
/photos/{p} |
Delete (or reject) one photo. 204 |
|
GET |
/photos/{p}/thumb.jpg, /photos/{p}/image.jpg |
The 320 × 200 and 1280 × 800 images. Work for pending photos | |
GET |
/frames/{f}/photo-set |
The frame's stored daily set, read-only |
GET /photo-library
{
"limits": { "library_bytes": 5368709120, "album_photos": 5000, "drop_per_day": 300, "upload_bytes": 31457280, "pending_album": 500, "pending_bytes": 536870912 },
"usage": { "bytes": 123456789, "photos": 812, "pending": 3 },
"imagick": { "ok": true, "code": null, "heic": true },
"sandbox": { "mode": "bwrap", "active": true }
}
The key imagick kept its old name; it describes the image decoder. code is decoder_unavailable or sandbox_unavailable when ok is false (then uploads answer 503). heic is false when iPhone photos cannot be processed. sandbox.mode is bwrap, user, auto or none.
Albums: an album is {id, name, created_at, count (approved), pending, bytes (all), newest_at, used_by: [{frame_id, name}]}. Create answers 201 with Location; 409 for a duplicate name.
GET /photo-albums/{a}/photos
takes status (pending or approved; omit for both), before (a photo id) and limit (1 to 200, default 60). It answers {items: [photo], next_before: id or null}, newest first.
POST /photo-albums/{a}/photos
: the body is one raw image, Content-Type: image/* or application/octet-stream. The bytes decide the type: JPEG, PNG, WebP and HEIC are accepted.
| Status | Code | Meaning |
|---|---|---|
201 |
{duplicate: false, photo} with Location |
|
200 |
{duplicate: true, photo}: these exact bytes are already in the album |
|
404 |
No such album | |
409 |
album_full |
|
413 |
too_large |
Over upload_bytes |
415 |
Not an image type, or the bytes are not JPEG, PNG, WebP or HEIC | |
422 |
undecodable, unsupported_color (CMYK), unsupported_animation |
Corrupt, too large in pixels (over 12,000 pixels a side or 64 megapixels), or refused by the decoder |
503 |
decoder_unavailable, sandbox_unavailable, heic_unavailable, busy |
busy carries Retry-After |
507 |
storage_full, pending_full |
Admin uploads are approved at once.
Deletions and approvals take {ids: [1, 2, …]} with 1 to 500 integers. Deletions answer {deleted: [ids], missing: [ids]}; approvals answer {approved: [ids], missing: [ids]}. missing lists ids not in that album. Approving an already approved photo counts as approved.
Images are image/jpeg, nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cross-Origin-Resource-Policy: same-origin. Approved photos are cached (private, max-age=86400, with an ETag); pending ones are no-store.
GET /frames/{f}/photo-set
answers {enabled, album: {source, host, name, count} or null, tz, pick_time, picked_for, picked_at, next_pick_at, stale, photos: [{id, new, added_at, thumb}]}. It never picks a set or records anything.
Saving the source for a frame uses the widget endpoint: PUT /frames/{f}/widgets/photos with config: {source: "nextcloud" | "local", album_url, local_album, seconds}. album_url is a secret (omit to keep). GET returns info: {source, sources: [{id, label}], album_set, host, name, count, pending, error}.
Drop links (photos widget)
A drop link belongs to one album. The page it opens is public and write-only.
| Method | Path | Re-auth | Purpose |
|---|---|---|---|
GET |
/photo-albums/{a}/drop-links |
List ({items}) |
|
POST |
/photo-albums/{a}/drop-links |
Create. Body {name, expires_at} |
|
PATCH |
/photo-drop-links/{l} |
If revoked is sent |
Rename, revoke or restore, change expiry |
POST |
/photo-drop-links/{l}/link |
Yes | Rotate: new secret, old link dead at once |
DELETE |
/photo-drop-links/{l} |
Yes | Delete the link. Its photos stay |
DELETE |
/photo-drop-links/{l}/photos |
Yes | Delete every photo that came through the link |
A link object: {id, album_id, name, created_at, last_used_at, expires_at, revoked_at, photos, pending}. name is 1 to 40 characters, unique within the album. expires_at must be in the future and at most 366 days away; the default is 90 days.
Create answers 201 {drop_link, link}, where link is https://example.com/frames/drop/<43 characters>. Rotating answers 200 with the same shape. The link is shown once and is never returned again. Responses that carry a link are Cache-Control: no-store. Restoring a revoked link (revoked: false) makes the old link work again; rotate if it may have leaked.
Cards (notes widget)
Cards are greeting cards sent from family through personal links. The admin never reads their text.
| Method | Path | Re-auth | Purpose |
|---|---|---|---|
GET, PUT |
/frames/{f}/notes |
Cards settings: {enabled, keep_days (1 to 30), recipient_label (at most 24 characters or null), categories (list of slugs or null for all)}. The GET also returns all_categories and limits |
|
GET, POST |
/frames/{f}/note-senders |
List senders, create one (body {name}, 1 to 40 characters). Create answers 201 {sender, link} |
|
PATCH |
/note-senders/{id} |
If revoked is sent |
Rename, revoke or restore. Restoring rotates the link and returns it as link |
POST |
/note-senders/{id}/link |
Yes | Rotate the link: 200 {link} |
DELETE |
/note-senders/{id} |
Yes | Delete. 409 in_use if the sender has sent cards; revoke instead |
DELETE |
/note-senders/{id}/cards?status=unopened |
Yes | Delete the sender's unopened cards: 200 {deleted} |
GET, POST |
/note-designs, /note-stamps |
List; upload one image | |
PATCH, DELETE |
/note-designs/{id}, /note-stamps/{id} |
Enable or disable ({enabled}; designs also {category}); delete (409 in_use once cards use it) |
|
GET |
/note-cards |
Card metadata: frame_id, status (opened or unopened), sender_id, before, limit (1 to 100, default 25) |
|
DELETE |
/note-cards/{id} |
Delete one unopened card. 409 opened otherwise |
Sender objects are {id, frame_id, name, created_at, revoked_at, last_used_at, cards_sent}. The link is never part of a sender.
Art uploads send the raw image with Content-Type: image/jpeg, image/png or image/webp (at most 4 MiB, minimum and maximum pixel sizes apply). Designs also need ?category=<slug>. A new image answers 201; an image already in the library answers 200, or 409 duplicate if it is already filed under another category. An item is {id, [category], url, width, height, bytes, enabled, created_at, uses}.
/note-cards items are {id, frame_id, frame, sender_id, from, category, created_at, opened_at, cover_url, stamp_url} with next_before.
Public endpoints
These need no account. They need the secret from a link.
Drop page and upload
| Method | Path | Purpose |
|---|---|---|
GET |
/frames/drop/<token> |
The upload page (HTML) |
GET |
/frames/drop/assets/drop.js, drop.css |
Its script and styles |
GET |
/frames/api/v1/drops/<token> |
What the page needs to greet the sender |
POST |
/frames/api/v1/drops/<token>/photos |
Upload one photo |
<token> is 43 characters from A-Za-z0-9_-. Pages and API replies carry Referrer-Policy: no-referrer and X-Robots-Tag: noindex, nofollow. A cross-site request (Sec-Fetch-Site other than same-origin or none, or a foreign Origin) is 403 bad_origin.
GET /api/v1/drops/<token>
answers:
{ "link": "Aunt Sue", "album": "Family", "limits": { "upload_bytes": 31457280 },
"remaining": { "hour": 97, "day": 280 }, "expires_at": 1797776000 }
remaining is only this link's own quota.
POST /api/v1/drops/<token>/photos
: one raw image per request, Content-Type: image/* or application/octet-stream. The photo lands pending. Success is always the same answer, whether the photo is new, a duplicate of a pending one or a duplicate of an approved one:
{ "received": true }
(status 202). Errors use the standard shape:
| Status | Code | What the sender should do |
|---|---|---|
401 |
unauthorized |
Stop. The link is unknown |
403 |
revoked, expired, bad_origin |
Stop |
413 |
too_large |
Skip this file |
415, 422 |
unsupported_media_type, undecodable, unsupported_color, unsupported_animation |
Skip this file |
429 |
rate_limited |
Wait Retry-After, then retry |
503 |
busy (Retry-After: 5) or unavailable (Retry-After: 60) |
Wait, then retry |
507 |
unavailable |
Stop and try again later. The album, the queue or the disk is full; the reason is not disclosed |
Card page and API
| Method | Path | Purpose |
|---|---|---|
GET |
/frames/notes/<token> |
The sender page (HTML) |
GET |
/frames/notes/assets/{notes.js,notes.css,card.css,stamp.css} |
Its files |
GET |
/frames/notes/art/<sha256>.<ext> |
A cover or stamp image (<ext> is jpg, png or webp). Only files the database lists; cached for a year |
GET |
/frames/api/v1/notes/<token>/me |
Sender, recipient, allowed categories and limits |
GET |
/frames/api/v1/notes/<token>/designs?category= |
Covers in a category, with limit (1 to 100, default 24) and before |
GET |
/frames/api/v1/notes/<token>/cards |
The sender's own cards, newest first, with limit and before |
GET |
/frames/api/v1/notes/<token>/cards/{id} |
One of the sender's cards, with its signature |
POST |
/frames/api/v1/notes/<token>/cards |
Send a card |
POST …/cards
body (at most 32 KiB):
| Field | Rule |
|---|---|
client_id |
32 lowercase hex characters, chosen by the page. A repeat of an earlier send returns that card with 200 and counts against no limit |
design_id |
An enabled cover in an allowed category |
message |
1 to 400 characters, at most 8 lines |
signature |
A handwritten signature (strokes and points, with limits). Send exactly one of signature and signature_text |
signature_text |
1 to 40 characters |
Answers: 201 with the card and Location: cards/<id>; 409 not_enabled, 409 sender_full (10 unopened), 409 mailbox_full (30 unopened on the frame); 422; 429. A link that is revoked is 403 revoked; a frame that is not active is 403 not_active.
Related
- Security: why the authentication and limits are what they are
- Kiosk update protocol: the offer, download and telemetry in context
- Writing a widget: adding routes
- Database: the tables behind these endpoints