---
title: "The public API"
description: "Put content on screens from your own software — tokens, the layer stack, endpoints, limits, and the audit trail."
---

> Documentation Index
> Fetch the complete documentation index at: https://anysign.tv/llms.txt
> Use this file to discover all available pages before exploring further.

# The public API

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.

```jsonc
{
  "upload": { "maxBytes": 33554432 },
  "image": {
"maxBytes": 33554432,
"maxDimension": 4096,
"maxPixels": 8847360,
"unplayableMimes": ["image/webp"]
  },
  "video": { "maxBytes": 33554432 },
  "playlist": { "maxItems": 500 }
}
```

> **Read this before you upload a photo**
>
> `maxPixels` is 4096 × 2160 = 8,847,360, and it is the limit that surprises
> people: a straight-from-camera phone photo is typically 4032 × 3024 =
> 12.2 MP — *under* `maxDimension` on both sides and still refused, with
> `reason: "image_too_many_pixels"`. The byte limit is rarely the one you hit.

**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:

```js
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`

```jsonc
{ "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`

```jsonc
{ "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`

```jsonc
{ "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.

```sh
curl -X POST "$BASE/api/v1/content?name=menu.png" \
  -H "authorization: Bearer $TOKEN" \
  -H "content-type: image/png" \
  --data-binary @menu.png
```

```jsonc
{
  "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`

```jsonc
{ "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.

```jsonc
{
  "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`

```jsonc
{ "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.

Source: https://anysign.tv/docs/api/index.mdx
