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 (else 415) and must be a JSON object. A field the endpoint does not list is a 422. 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-8 with Cache-Control: no-store, except images, files and assets, which say otherwise.
  • A 201 carries a Location header. A 405 carries Allow. A HEAD request is treated as a GET.
  • 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.

json
{ "error": "human readable message", "code": "stable_code" }

A validation failure (422) adds the offending fields:

json
{ "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.

json
{
  "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": [] } }
}
  • v is <newest app file modification time>:<config_version>. When it changes, the page reloads itself.
  • pages lists the rail items the frame should show.
  • widgets has 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.

  • 200 image/jpeg with a strong ETag (the content hash). If-None-Match gets 304. Also X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cross-Origin-Resource-Policy: same-origin, Cache-Control: private, max-age=86400.
  • 404 unless 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.

  • GET returns the full card: {id, from, sent_at, opened_at, cover, stamp, message, signature, signature_text}. 404 if the card is another frame's, cards are off, or it has aged out of the mailbox.
  • PATCH with {"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:

  1. A signed-in session cookie is required, else 401 auth_required with WWW-Authenticate: Cookie realm="frame-admin".
  2. The request must be same-origin (Sec-Fetch-Site: same-origin, or a matching https Origin), reads included, else 403 bad_origin.
  3. Anything but a GET needs the header X-CSRF-Token (from GET /api/session), else 403 bad_csrf.
  4. JSON bodies are at most 16 KiB.
  5. The "Re-auth" column marks endpoints that need the password entered in the last five minutes. They fail with 403 reauth_required until POST /api/session/reauthentications succeeds.

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:

json
{
  "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:

json
{
  "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:

json
{
  "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

json
{
  "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}.

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:

json
{ "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:

json
{ "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.