Skip to content

The public API

Put content on screens from your own software — tokens, the layer stack, endpoints, limits, and the audit trail.

Updated View as Markdown

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 loop

Writing 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 item

Exactly 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" }       // admin

Commands 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 lastUsedAt makes a forgotten token visible in the console.
  • No webhooks out, no proof-of-play export, no SSO.
  • No Retry-After on 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 curl each; 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close