One API, two credentials. The console is a consumer of it like any program
is: a session cookie for a person, a bearer token for a program, the same
routes behind both. Everything lives under /api/v1; this page is the
curated subset an integration should build against.
Getting a token
Team → API tokens → New token, as an admin. The secret is shown once; only its hash is stored. Revoking is immediate.
A token has a member’s authority — it changes what a screen shows, never
the screen itself. It cannot claim a screen, reboot one, reconfigure one,
invite anyone, or issue another token; everything marked admin answers
403.
Send it on every request:
Authorization: Bearer anysign_<64 hex characters>Errors use one envelope everywhere: a machine reason, an English
message, and params where the sentence names a limit.
The model: a stack, not a slot
A screen’s content is a stack of layers, and the screen shows the highest layer that has something on it:
emergency org-wide takeover
takeover "show this now" — an operator's, or yours
source an integration holds this screen
scheduled dayparting
base the curated loopWriting one layer never touches another; taking a layer off uncovers whatever is beneath. The convention that makes sharing a screen safe: an integration owns a layer, not a screen. Write your own tier and an operator can always take the screen back above you, and always see what you covered.
Limits
GET /limits
The ceilings this API judges a request against. Read them rather than hardcoding them — they can be raised without a release on your side.
{
"upload": { "maxBytes": 33554432 },
"image": {
"maxBytes": 33554432,
"maxDimension": 4096,
"maxPixels": 8847360,
"unplayableMimes": ["image/webp"]
},
"video": { "maxBytes": 33554432 },
"playlist": { "maxItems": 500 }
}There is no server-side resize. The stored bytes are what gets hashed for dedupe, so a transform would defeat content addressing. Resizing is yours — six lines in a browser:
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(
1,
4096 / Math.max(bmp.width, bmp.height),
Math.sqrt(8847360 / (bmp.width * bmp.height)),
);
const canvas = Object.assign(document.createElement("canvas"), {
width: Math.floor(bmp.width * scale),
height: Math.floor(bmp.height * scale),
});
canvas.getContext("2d").drawImage(bmp, 0, 0, canvas.width, canvas.height);
const blob = await new Promise((r) => canvas.toBlob(r, "image/jpeg", 0.9));imageOrientation: "from-image" matters: without it a portrait photo is
measured on the wrong axis and then rotated again by the renderer.
Screens
GET /screens
{ "screens": [ {
"id": "…", "name": "Lobby", "site": "HQ",
"lastSeenAt": 1756400000000,
"connectivity": "online", // online | offline | never-seen
"state": "converged", // converged | syncing | no-content | unknown
"converged": true,
"content": { "layer": "takeover", "kind": "asset", "assetId": "…", "name": "menu.png" },
"layers": [ /* the whole stack, highest first */ ]
} ] }converged is the field to build a sentence on. It is true only when
the screen is talking to us, has something to show, and reports the version
we last told it — never merely that the server sent it. content is the top
of the stack. A screen that stops reporting goes unknown, whatever it last
said.
GET /screens/:screenId returns one, with liveness detail and what the
device last reported about itself.
PUT /screens/:screenId/layers/:layer
{ "playlistId": "…" } // loop a playlist
{ "assetId": "…", "durationMs": 15000 } // hold the screen with one itemExactly one of the two; :layer is one of the five above. A playlist must
exist; an asset must have finished uploading — a screen pointed at bytes
that are not there yet counts them missing and stages forever.
DELETE /screens/:screenId/layers/:layer
Take one layer off; whatever is beneath shows through. Clearing a layer that was already clear is success: you wanted it gone and it is gone.
DELETE /screens/:screenId/layers
Empty the whole stack — the blunt tool for “make this screen blank”.
POST /screens/:screenId/commands
{ "type": "refetch" } // member: re-sync now
{ "type": "screenshot" } // member: capture what is on the glass
{ "type": "reboot" } // adminCommands ride the screen’s next check-in, and their outcome is reported
back. refetch is a repair tool for a device holding wrong bytes, not a
fast path — ordinary publishing reaches screens on their own check-in.
Uploading content
POST /content?name=<filename>[&durationMs=<ms>]
The body is the file; its type is the Content-Type header.
curl -X POST "$BASE/api/v1/content?name=menu.png" \
-H "authorization: Bearer $TOKEN" \
-H "content-type: image/png" \
--data-binary @menu.png{
"assetId": "…", "sha256": "…", "kind": "image", "bytes": 20480,
"width": 1920, "height": 1080, "deduped": false
}width and height are what the server measured, and they are null when
it could not — the file is stored either way, but a null here is your cue
that nothing will render if you point a screen at it. Assert on them before
you do.
Re-uploading identical bytes is a no-op that answers 200 with the
asset that already exists (deduped: true), including when two pushes race
each other. That is deliberate, because the natural shape of a connector is
“push everything, every time”.
The sha256 in the answer is computed from the bytes we stored, and the image guardrails (longest side, total pixels) run against the file’s own PNG/JPEG/GIF header — an image a TV panel cannot decode is refused at the door rather than taking a screen down.
Limits: 32 MB per request, images and video only, and no WebP — it uploads fine, syncs fine, and never draws on the panel.
Above 32 MB, use the multipart flow: POST /assets with the file’s
sha256, mime, byte count and kind answers an assetId; PUT /assets/:assetId/parts/:n takes ≤10 MB parts and answers an etag each;
POST /assets/:assetId/complete with the parts closes it, POST /assets/:assetId/abort abandons it. The console uses this for video because
a browser needs to resume over venue wifi; it is open to you for the same
reason. Here the sha256 is your word — a lie poisons only your own library,
since the device verifies on download.
GET /assets, GET /assets/:assetId/content
The org’s ready assets; the second returns the bytes, Range-capable.
PATCH /assets/:assetId
{ "name": "lobby-final.png" }Display only. The sha256 is the identity, so a rename moves no bytes and invalidates nothing a screen holds.
DELETE /assets/:assetId
For integrations that produce content on a schedule and would otherwise
produce library entries on a schedule too. Answers 409 rather than
deleting when something is holding the asset, and the error names what:
reason |
Meaning |
|---|---|
asset_still_uploading |
A multipart upload owns this row; abort it instead. |
asset_in_playlists |
Remove it from the named playlists first. |
asset_on_screens |
A screen is showing it. Clear or replace that screen’s layer first. |
The last one is not politeness: deleting bytes a screen is pointed at leaves that device counting the file missing forever, and it never converges again.
Playlists
GET /playlists lists them. GET /playlists/:playlistId returns one with
its items, so you can reconcile against what you last wrote without keeping
a copy of our state.
{
"playlist": { "id": "…", "name": "Photo wall" },
"items": [ {
"id": "…", "position": 0, "assetId": "…", "sha256": "…",
"kind": "image", "name": "guest-0042.jpg", "durationMs": 5000
} ]
}POST /playlists with { "name": "…" } answers 201 and
{ "playlistId" }. PATCH /playlists/:playlistId renames.
DELETE /playlists/:playlistId removes one, and answers
409 playlist_on_screens naming the screens if any are assigned it —
deleting what a wall is showing has to be an explicit two-step.
PUT /playlists/:playlistId/items
{ "items": [
{ "assetId": "…", "durationMs": 5000 },
{ "assetId": "…" }
] }This replaces the whole list, and that is the feature: send the list you
want and never compute a diff. Re-sending an unchanged list is harmless.
Order is the array’s order. durationMs is optional — a video’s own
duration is used, and a default for stills. At most playlist.maxItems
entries.
An item whose asset does not exist or has not finished uploading is refused
as playlist_item_asset_not_found / playlist_item_asset_not_ready, naming
the assetId, and nothing is written.
Rate limits
Two budgets, and they meter different things on purpose.
| Budget | Keyed on | Size | reason on 429 |
|---|---|---|---|
| Requests that authenticate with a token | your token | 120 / minute | too_many_api_requests |
| Requests that fail to authenticate | your address | 20 / minute | too_many_attempts |
The first is the one to pace against. It is keyed on the token, so one program’s runaway loop cannot spend another’s budget, and it charges tokens only — a program cannot starve an operator at the console, and an operator cannot starve a program. The second only ever charges refusals — a request carrying a valid token never touches it; in practice you only meet it by deploying a revoked or mistyped token and retrying hard.
Both are per-minute and counted per serving location, so treat them as a ceiling to back off from, not an allowance to spend exactly. A 429 carries the same envelope as every other refusal; back off and retry — neither budget is a ban.
What this does not do yet
Named so nobody thinks it was forgotten:
- No per-screen or per-layer scoping. A token acts across its whole organization and may write any layer; owning a layer is a convention you keep, not one the server enforces yet.
- No leases. Two programs writing the same layer are last-writer-wins — which is why every write here is audited.
- No expiry on tokens. Revocation is the control, and
lastUsedAtmakes a forgotten token visible in the console. - No webhooks out, no proof-of-play export, no SSO.
- No
Retry-Afteron a 429. Both budgets are per-minute, so a minute is always enough. - No server-side resize — see the limits above. A decision, not a gap.
- No push: content lands within one screen check-in (about 20 seconds) rather than instantly.
- No SDK. Half of these routes are one
curleach; the rest take a JSON body.
The audit trail
Every write above is recorded — by people and programs alike — with the
actor’s name as it read at the time, so revoking and renaming cannot
rewrite history. Read it at GET /audit, or under Team → Activity in
the console. Once two writers share a screen, this is what makes sharing it
safe.