Skip to content

The public API

Put content on screens from your own software — tokens, the group tree, one content slot, 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.

A token also has a scope: the groups and screens it may see and change, chosen when it is issued (POST /tokens { name, scope? } where scope is { groupIds? } or null) and changeable later (PUT /tokens/:tokenId/scope { scope }, admin — a token cannot widen its own view, and an admin can only change a token whose view lies within their own). A token with no grants acts across the whole organization; GET /tokens lists each token’s scope, null for those. This body replaces the top-level groupIds / screenIds fields of the first release, and screenIds is gone: a token needing exactly one screen gets a group of one. A scoped token sees the screens in the groups it holds and in the groups beneath them, and every screen route answers 404 for anything else. Membership is read live, so a screen joining a held group is in the token’s view on its next request.

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: one slot, and a tree of groups

A screen shows exactly one thing: a playlist to loop, or a single asset. That is its slot, and every write to it is the whole story — there is nothing underneath, no tier to uncover. Most recent wins: a push to a group a minute after someone set one screen by hand changes that screen; a push to the screen a minute later changes it back. Every write is on the audit trail with what it replaced, and GET /screens/:screenId/history reads the last things a screen showed.

Screens live in a tree of groups with one root per organization, named after it. A screen is in exactly one group; being in “Lobby” is being in “Branch A”. Showing something on a group shows it on every screen beneath it and is remembered by the group, so a screen moved in later adopts it.

Scope

A principal’s view of the fleet — a person’s or a token’s — can be narrowed to some groups (Team → Access for people; the token form and Team → API tokens → Access for tokens, as an admin). A principal with no grants holds the root, which is the whole organization, and that is everyone until an admin narrows them. Holding a group is holding everything beneath it: a scoped principal sees the screens in their groups and in the groups under them, and nothing else. Every screen route answers 404 for a screen outside their view, the way it does for another organization’s, because one accessor enforces both.

One rule decides everything else. A group is in view if and only if it is held or sits beneath one that is. A scoped principal creating a group under one they hold needs no grant for it. Claiming grants nothing: a narrowed caller must name a group they hold (422 claim_group_required), and an admin of the organization may name none, leaving the screen at the root. An editor can hand on only what they hold, never the whole organization (403 scope_cannot_widen) — so a delegate’s view is always inside the delegator’s. And an editor can change, narrow, remove or revoke only a principal whose whole view lies within their own (403 outside_scope): a scoped admin cannot re-scope the organization’s integration token or a colleague who sees more than they do.

Roles are untouched by scope — a scoped admin claims, reboots and revokes their screens and not others. On the wire, in both directions, a scope is { groupIds } or null for the whole organization; an empty list means no access — a state you can ask for (a parked contractor, a hire whose branch is not decided), and what someone is left with when the only group they held is deleted (their grant goes with the group; they are not widened to everything). The root’s id in groupIds means the whole organization and is normalised to null. Requests carry it as scope: PUT /tokens/:tokenId/scope { scope }, and an optional scope on POST /tokens, where omitted means null; people are scoped from the console the same way. An invitation that named targets and has none left is refused at acceptance (409 invite_scope_stale) and left pending. The content library stays org-wide; the delete guards still refuse for the whole organization but name only the screens you can see (409 playlist_on_hidden_screens, playlist_on_unseen_screens, and the asset_ twins). Offline emails and the audit log are filtered to the reader’s view.

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",
  "group": { "id": "…", "name": "Lobby", "path": "Branch A / Lobby" },
  "lastSeenAt": 1756400000000,
  "connectivity": "online",           // online | offline | never-seen
  "state": "converged",               // converged | syncing | no-content | unknown
  "converged": true,
  "revoked": false,
  "content": { "kind": "playlist", "playlistId": "…", "name": "Lobby loop" }
} ] }

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 slot, null when the screen shows nothing; group names the one group the screen is in. 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/content

{ "playlistId": "…" }                        // loop a playlist
{ "assetId": "…", "durationMs": 15000 }      // hold the screen with one item

Exactly one of the two. 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/content

The screen shows nothing. Clearing an empty slot is success: you wanted it gone and it is gone.

POST /screens/content

The same body plus screenIds: […] (up to 1000): one act over a set of screens, answered with { ok, screens, cause }. POST /audit/:cause/undo puts every screen it reached back to what its own audit row recorded.

GET /screens/:screenId/history

Up to twenty distinct things the screen showed, newest first, each with available: false when the playlist or asset has since been deleted.

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.

Groups

Groups form a tree, one root per organization and named after it. A screen is in exactly one group; being in “Lobby” is being in “Branch A”. Two things hang off a group: content, below, and access — a person or a token holding a group sees its whole subtree; see Scope above.

GET /groups lists the groups in your view, in reading order:

{ "groups": [ {
  "id": "…", "name": "Lobby", "parentId": "…", "path": "Branch A / Lobby", "depth": 2,
  "createdAt": 1756400000000,
  "screenIds": ["…"],            // directly in this group
  "content": { "kind": "playlist", "playlistId": "…", "name": "Lobby loop" },
  "grantees": 2, "soleHolders": 1
} ] }

The root has parentId: null and the organization’s id. Structure is admin: POST /groups { name, parentId } (409 group_name_taken among siblings), PATCH /groups/:groupId { name }, POST /groups/:groupId/move { parentId } (409 group_cycle inside itself), DELETE /groups/:groupId — its screens and child groups move up to its parent; every grant naming it goes, and whoever held nothing else is left with no access, not widened (409 group_root for the root, 409 group_is_grant_root for a group your own access is granted through). POST /screens/move { screenIds, groupId } puts screens in a group; the destination must be in your view, so a move never leaves it.

Content on a group is member, like content on a screen: PUT /groups/:groupId/content takes the body a screen takes and shows it on every screen beneath the group, remembering it on the group and every group beneath; DELETE /groups/:groupId/content clears them all. Both answer { ok, screens, cause }. GET /groups/:groupId/history reads the group’s recent pushes.

A group’s content is memory, not authority: the resolver reads the screen’s own slot and nothing else. A screen moved into a group whose slot holds something adopts it; moved into an empty one, it keeps what it showed.

POST /screens/claim takes groupId; a caller whose access is limited must name one they hold, an admin of the organization may leave it out and the screen sits at the root.

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 slot 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.

Templates and data sources

A template is an HTML file uploaded like any still: content-type: text/html on POST /content, at most 4 MB, one self-contained file — the screen draws it in a surface with no network, so fonts, icons and images go in as data URIs and video does not go in at all. It answers kind: "html". A manifest block inside it names the data it reads and the sample the console previews it with:

<script type="application/anysign+json">
{ "name": "Queue board", "sources": ["queue"],
  "sample": { "queue": { "serving": [{ "number": "A042", "room": "Room 1" }] } } }
</script>

The template reads anysign.data.queue and registers anysign.onData(cb) for changes. anysign.data.screen is always there — { id, name, group: { id, name, path } } — so one template serves every screen by reading which one it is on, and anysign.data.__meta.queue says { version, updatedAt, kind, stale }.

A data source is the JSON a template receives, by name. Three kinds: manual (typed in the console), push (written by you), fetch (read from a URL on a cadence by the service). A value is at most 64 KB of UTF-8 — a Japanese or Chinese board holds about a third of the characters an English one does; at most 50 sources per organization; a name is ^[a-z][a-z0-9_-]{0,63}$ and may not be screen.

PUT /data/:name

The push. The body is the value, whole:

curl -X PUT "$BASE/api/v1/data/queue" \
  -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
  -d '{"serving":[{"number":"A042","room":"Room 1"}],"waiting":["A043"]}'
{ "ok": true, "version": 1042 }

Every screen showing a template that names queue is told within about a second, and its document changes, so converged on GET /screens means “showing this value”. Only a push source takes a PUT; a manual or fetch one answers 409 data_source_not_pushable. The push has its own budget — 600 a minute per token, apart from the 120 below — so a queue system pushing on every call neither starves the console nor is starved by it.

GET /data, POST /data, GET|PATCH|DELETE /data/:name

List, create, read, configure, delete. POST /data takes { "name", "kind": "push", "config": { "staleAfterSec": 120 } }, { "name", "kind": "manual", "value": {...} } or { "name", "kind": "fetch", "config": { "url", "refreshSec": 300, "headers": { "x-api-key": "…" } } }. Header values are never returned, only their names. A fetch url must be http: or https:. A fetch source is read at most once a minute; a body over 64 KB is refused without being held, a failed read changes nothing but lastError, and stale turns true once two refreshes pass without a good read.

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 protected notice. A push to the root reaches every screen and a later push to any screen wins there — which is why every write here is audited, and why undo on a push’s cause is the way back.
  • 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.
  • Templates need the player build that draws them. A screen’s document carries an html item and its data today; the surface that draws it is built and awaits its release. A player without it skips the item.

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