API reference
Every CueStride API route with its fields and answers, the limits, the refusals, retries, and the stream of a room's changes with a client to copy.
Last checked against CueStride on 27 October 2026.
Everything the API has, for when your first requests work and your
tool needs more. Paths are under https://app.cuestride.com/api/v1. A tool that reads OpenAPI can take the API's OpenAPI description instead, which this
reference is built from; it needs no key.
Retries
A control command or an edit may carry a Command-Id header, a UUID your tool makes. Sending
the same command again with the same id answers as the first did, with replayed: true, and
applies nothing twice, so a tool may retry when an answer is lost. Without an id, each request is
a new command. Flash keeps no record, so it takes no id, and a flash sent again flashes again.
A playback command may also carry ifRevision, in its body or in a button link’s query: it
applies only when the room is at that revision, and otherwise answers 409 REVISION_CONFLICT.
The ids in a path, and the headers a request may carry:
| Name | In | Value | Notes |
|---|---|---|---|
roomId | path | text | The room's id as the app shows it, without its hyphen. It is also read in lower case, with a hyphen or spaces, and with O, I, and L as 0, 1, and 1. |
timerId | path | a UUID | |
messageId | path | a UUID | |
Command-Id | header | a UUID | A UUID. A repeat of a command that succeeded replays its answer; the same id with another body, or on another route, answers 409 COMMAND_ID_REUSED, and one another caller used answers 403 COMMAND_ID_FOREIGN. A refused command keeps no receipt, so its id may be sent again. Flash reads none. |
Idempotency-Key | header | a UUID | The same as Command-Id; both, with different ids, answer 400 VALIDATION. Flash reads neither. |
If-None-Match | header | text | The ETag of the room's revision the caller holds: 304 when unchanged. |
Limits
Each key, each account, and the server have buckets of requests. A bucket holds at most the number under At once, and fills again at the rate under A minute, a little at a time:
| What | A minute | At once |
|---|---|---|
| A key's status reads | 300 | 300 |
| A key's other reads, its stream's opening among them | 60 | 60 |
| A key's control commands and edits | 120 | 30 |
| The same, for a key of an account on Starter | 30 | 30 |
| Every read of an account's keys together | 6,000 | 2,000 |
| Every write of an account's keys together | 300 | 60 |
| Every write of every key together | 1,200 | 120 |
| Requests with no key or an unknown one, per address | 60 | 120 |
| Requests with a revoked or expired key, per address and key | 6 | 12 |
| Keys an account's admins make and replace | 30 | 30 |
A read or a write that sends a room’s rundown or its messages counts once for every
100 items the list holds, at least once: a reorder of a rundown of
500 counts as 5. Every answer to a
key the server accepted, a 200 or a 304, carries RateLimit-Limit, RateLimit-Remaining, and
RateLimit-Reset, the seconds until its bucket is full again, and so does a 429 from that key’s
own bucket. A request refused before its key is accepted, with no key, an unknown, revoked, or
expired one, or one in the wrong place, carries none. A request over the limit is refused with 429 RATE_LIMITED and a
Retry-After, the seconds until one request is free. A body is read up to
64 KB, nested at most 16 deep.
Refusals
Every refusal is JSON with a code and a message, such as
{"ok": false, "error": {"code": "SCOPE_FORBIDDEN", "message": "this key has no control scope"}}:
| Status | Codes | When |
|---|---|---|
400 | VALIDATION, INVALID_AMOUNT, KEY_IN_URL, KEY_CONFLICT | VALIDATION, INVALID_AMOUNT, KEY_IN_URL (a key in a query where none may go, or from a key not made for links), or KEY_CONFLICT (a key in the header and the query). |
401 | KEY_REQUIRED, KEY_INVALID | KEY_REQUIRED, or KEY_INVALID: unknown, revoked, or expired. |
403 | SCOPE_FORBIDDEN, KEY_DISABLED, ROOM_DISABLED, API_OFF, NOT_A_PRESS, PLAN_LIMIT, TIMER_DISABLED, COMMAND_ID_FOREIGN | SCOPE_FORBIDDEN, KEY_DISABLED (beyond the plan's count, with limit and plan), ROOM_DISABLED, API_OFF, NOT_A_PRESS (a prefetch, a prerender, or a link preview), PLAN_LIMIT (with limit, used, and plan), TIMER_DISABLED, or COMMAND_ID_FOREIGN (a command id another caller used). |
404 | ROOM_NOT_FOUND, TIMER_NOT_FOUND, MESSAGE_NOT_FOUND, NOT_FOUND | ROOM_NOT_FOUND (also a room outside the key's reach), TIMER_NOT_FOUND, MESSAGE_NOT_FOUND, or NOT_FOUND (an id that is not a UUID). |
409 | REVISION_CONFLICT, COMMAND_ID_REUSED, LIMIT_REACHED | REVISION_CONFLICT (ifRevision is not the room's revision), COMMAND_ID_REUSED (a command id sent again with another body), or LIMIT_REACHED (a room's most timers or messages). |
413 | TOO_LARGE | TOO_LARGE: a body over 64 KB. |
429 | RATE_LIMITED, STREAM_LIMIT | RATE_LIMITED, or STREAM_LIMIT: wait for Retry-After. |
500 | INTERNAL | A fault in the server, on any route, in the same error envelope. |
The stream
GET /rooms/{roomId}/events is the room’s stream, as a moderator sees it: every message, and the
rundown with its notes blank. Its first event is a snapshot of the room; then an update comes
for every change, with the next revision, and a heartbeat every 15 seconds. A key holds one
stream in each room, and 10 across rooms: a new stream of the same key
in a room replaces its old one there, which gets kicked with replaced. A key’s stream counts
as one of the room’s live connections, so in a full room it is refused with 403 PLAN_LIMIT.
A stream also ends with kicked and its reason when its key is revoked (revoked), expires
(expired), or goes beyond the plan (disabled); when Replace secret retires the secret it was
opened with (secret replaced); when the room turns API keys away (api off); or when an operator
kicks it in Live connections (disconnected by the operator).
An update sends a changed list whole, unless the stream asks for only what changed, with
?lists=items. A changed list may then come as { remove, patch, add }: the ids that left, the
fields that changed with each item’s updatedAt, and the new items. Apply them in that order and
sort by position. A list may still come whole, as an array, so accept both, as the client below
does. When an update does not follow on from your revision, or its changes do not
fit your copy, open the stream again: its first event is a fresh snapshot. Each heartbeat also
carries a digest of the lists, so a copy that drifted is found and reopened too.
The client below does all of this, and it waits out a dropped stream or a server that restarts.
It stops when the server ends the stream with kicked, and gives the reason; a tool may then ask
for a new secret or wait for the operator. CueStride’s tests run this file as it is, against the
server, so it stays right. Copy it whole into your tool:
key-stream.js// @ts-check
/**
* Follow a CueStride room from an API key's stream, with only the items that changed.
*
* This is the reference client the API docs show, and CueStride's tests run this file as it is,
* against the server. Plain JavaScript with no dependencies, for Node 20 or later and current
* browsers: copy it whole.
*
* It keeps a copy of the room as a moderator sees it: the room, the playback, the rundown with
* timer notes blank, and every message. It applies each update. When an update does not follow on,
* does not fit, or a heartbeat's digest says the copy drifted, it reopens the stream, whose first
* event is a fresh snapshot. It never mends the copy from the list reads, which carry notes.
*/
/**
* @typedef {{ id: string, position: number, updatedAt: number, [field: string]: unknown }} Item
* @typedef {{ remove?: readonly string[], patch?: ReadonlyArray<{ id: string, [field: string]: unknown }>, add?: readonly Item[] }} ListChanges
* @typedef {{ revision: number, room: unknown, playback: unknown, timers: Item[], messages: Item[] }} RoomCopy
* @typedef {{ end: "kicked", reason: string } | { end: "reopen" } | { end: "dropped" }} Outcome
*/
/** No event of any kind for this long, and the connection is taken for dead. */
const SILENCE_MS = 45_000;
/** The wait before reopening a stream that dropped: doubling from the first to the last. */
const FIRST_WAIT_MS = 2_000;
const LAST_WAIT_MS = 10_000;
/** The longest wait a timer holds: past it, Node fires at once. */
const LONGEST_WAIT_MS = 2_147_483_647;
/**
* One list's changes applied: remove, then patch, then add, then sort by position. A patch sets
* each field it names, null included, and leaves the rest. Null when they do not fit: an id the
* list does not hold removed or patched, one it holds added, or positions that are not 0 to n−1.
* @param {readonly Item[]} list
* @param {ListChanges} changes
* @returns {Item[] | null}
*/
export function applyChanges(list, changes) {
const items = new Map(list.map((item) => [item.id, item]));
for (const id of changes.remove ?? []) {
if (!items.delete(id)) {
return null;
}
}
for (const fields of changes.patch ?? []) {
const item = items.get(fields.id);
if (item === undefined) {
return null;
}
items.set(fields.id, { ...item, ...fields });
}
for (const item of changes.add ?? []) {
if (items.has(item.id)) {
return null;
}
items.set(item.id, item);
}
const next = [...items.values()].sort((a, b) => a.position - b.position);
return next.every((item, i) => item.position === i) ? next : null;
}
/**
* An update applied to a copy: `room` and `playback` replace theirs, a list comes whole as an
* array or as item changes, and a section that is absent keeps its own. Null when a list's
* changes do not fit.
* @param {RoomCopy} copy
* @param {{ revision: number, changes: { room?: unknown, playback?: unknown, timers?: Item[] | ListChanges, messages?: Item[] | ListChanges } }} update
* @returns {RoomCopy | null}
*/
export function applyUpdate(copy, update) {
const { room, playback, timers, messages } = update.changes;
const nextTimers = listAfter(copy.timers, timers);
const nextMessages = listAfter(copy.messages, messages);
if (nextTimers === null || nextMessages === null) {
return null;
}
return {
revision: update.revision,
room: room === undefined ? copy.room : room,
playback: playback === undefined ? copy.playback : playback,
timers: nextTimers,
messages: nextMessages,
};
}
/**
* @param {Item[]} list
* @param {Item[] | ListChanges | undefined} section
* @returns {Item[] | null}
*/
function listAfter(list, section) {
if (section === undefined) {
return list;
}
return Array.isArray(section) ? section : applyChanges(list, section);
}
/**
* A list's digest, as a heartbeat sends it: each item's JSON, its keys sorted at every level and
* its top-level `updatedAt` left out, hashed; each hash in base 36 followed by a comma; and that
* text hashed again, in base 36.
* @param {readonly Item[]} list
* @returns {string}
*/
export function listDigest(list) {
let text = "";
for (const item of list) {
text += `${hash53(canonical(item, true)).toString(36)},`;
}
return hash53(text).toString(36);
}
/**
* JSON with every object's keys sorted, and without `updatedAt` at an item's top level.
* @param {unknown} value
* @param {boolean} [item]
* @returns {string}
*/
function canonical(value, item = false) {
if (value === null || typeof value !== "object") {
return JSON.stringify(value) ?? "null";
}
if (Array.isArray(value)) {
return `[${value.map((v) => canonical(v)).join(",")}]`;
}
const record = /** @type {Record<string, unknown>} */ (value);
const keys = Object.keys(record)
.filter((k) => record[k] !== undefined && !(item && k === "updatedAt"))
.sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(record[k])}`).join(",")}}`;
}
/**
* A 53-bit hash of a string's UTF-16 code units: two 32-bit multiply-xor lanes, mixed.
* @param {string} text
* @returns {number}
*/
function hash53(text) {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let i = 0; i < text.length; i += 1) {
const c = text.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 2654435761);
h2 = Math.imul(h2 ^ c, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
/**
* The events of a server-sent event stream as `{ event, data }`, whatever chunks they arrive in.
* Ending early, by `return` or `break` in the loop that reads them, closes the stream.
* @param {ReadableStream<Uint8Array>} body
* @returns {AsyncGenerator<{ event: string, data: string }>}
*/
export async function* serverEvents(body) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let event = "";
/** @type {string[]} */
let data = [];
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) {
return;
}
buffer += decoder.decode(chunk.value, { stream: true });
for (;;) {
const end = buffer.search(/[\r\n]/);
// A CR at the very end may be half of a CRLF still on its way.
if (end === -1 || (buffer[end] === "\r" && end === buffer.length - 1)) {
break;
}
const line = buffer.slice(0, end);
buffer = buffer.slice(buffer.startsWith("\r\n", end) ? end + 2 : end + 1);
if (line === "") {
if (data.length > 0) {
yield { event: event || "message", data: data.join("\n") };
}
event = "";
data = [];
continue;
}
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
if (field === "event") {
event = value;
} else if (field === "data") {
data.push(value);
}
}
}
} finally {
await reader.cancel().catch(() => {});
}
}
/**
* Follows a room until `signal` aborts or the server ends the key's stream: it returns the reason
* the server gave with `kicked`, such as "replaced", "revoked", "expired", "secret replaced",
* "disabled", "api off", or "disconnected by the operator", or "aborted". It calls `onCopy` with
* the copy after every change; the copy is frozen through and through, for reading only. A
* refusal that waiting will not mend, a 4xx such as a revoked key's 401 or a full room's 403,
* throws an Error with the
* answer's `status` and `code`, and so does an answer that is not an event stream. An error
* `onCopy` throws comes out as it is. A dropped stream, a network failure, or a 5xx, such as a
* proxy's 502 while the server restarts, is waited out and the stream opened again.
* @param {{
* url: string,
* key: string,
* onCopy: (copy: Readonly<RoomCopy>) => void,
* signal?: AbortSignal,
* request?: typeof fetch,
* wait?: (ms: number, signal?: AbortSignal) => Promise<void>,
* silenceMs?: number,
* }} options `url` is the room's stream, such as
* https://app.cuestride.com/api/v1/rooms/K7QW3ND2XA/events, and `key` one that reads the room.
* @returns {Promise<string>}
*/
export async function followRoom({
url,
key,
onCopy,
signal,
request = fetch,
wait = pauseFor,
silenceMs = SILENCE_MS,
}) {
let pause = FIRST_WAIT_MS;
for (;;) {
if (signal?.aborted) {
return "aborted";
}
// One connection's own abort: the caller's signal, or a silence that outlasts the heartbeats.
const connection = new AbortController();
const stop = () => connection.abort();
signal?.addEventListener("abort", stop);
let silence = setTimeout(stop, silenceMs);
const heard = () => {
clearTimeout(silence);
silence = setTimeout(stop, silenceMs);
};
const progress = { connected: false, applied: 0 };
/** @type {Outcome} */
let outcome = { end: "dropped" };
/** How long a 429 says to wait, or null. */
let told = null;
try {
const res = await request(`${url}?lists=items`, {
headers: { authorization: `Bearer ${key}` },
signal: connection.signal,
});
if (res.status === 429) {
// Too many opens, or ten streams for this key already: wait as long as it says.
await res.body?.cancel();
told = retryAfterMs(res.headers.get("retry-after"), pause);
} else if (res.status >= 500) {
// The server or a proxy in front of it, restarting: as a stream that dropped.
await res.body?.cancel();
} else if (!res.ok || res.body === null) {
throw await refusal(res);
} else if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) {
await res.body.cancel();
throw Object.assign(new Error(`${url} is not a room's stream`), {
status: res.status,
code: "NOT_A_STREAM",
});
} else {
outcome = await follow(res.body, onCopy, heard, progress);
}
} catch (error) {
if (error !== null && typeof error === "object" && "caller" in error) {
throw error.caller;
}
if (/** @type {{ status?: number }} */ (error).status !== undefined) {
throw error;
}
// The network failed, or the silence ended the connection: as a stream that dropped.
} finally {
clearTimeout(silence);
signal?.removeEventListener("abort", stop);
}
if (outcome.end === "kicked") {
return outcome.reason;
}
if (signal?.aborted) {
return "aborted";
}
if (told !== null) {
await wait(told, signal);
continue;
}
// After a stream that delivered its snapshot, the least wait again, however it ended.
if (progress.connected) {
pause = FIRST_WAIT_MS;
}
// A copy that needs a fresh snapshot after applying updates reopens at once; one that needs it
// again before any update waits, so a change that never fits cannot spin the client.
if (outcome.end === "reopen" && progress.applied > 0) {
continue;
}
await wait(pause, signal);
pause = Math.min(pause * 2, LAST_WAIT_MS);
}
}
/**
* The milliseconds a Retry-After asks for, in seconds or as a date; `fallback` when it says
* neither; never less than a second, nor more than a timer holds.
* @param {string | null} value
* @param {number} fallback
* @returns {number}
*/
function retryAfterMs(value, fallback) {
if (value === null || value.trim() === "") {
return fallback;
}
const clamp = (/** @type {number} */ ms) => Math.min(Math.max(ms, 1000), LONGEST_WAIT_MS);
const seconds = Number(value);
if (Number.isFinite(seconds)) {
// Seconds as they are: a product too large for a number is past the cap anyway.
return clamp(seconds * 1000);
}
const at = Date.parse(value);
return Number.isFinite(at) ? clamp(at - Date.now()) : fallback;
}
/**
* A refusal as an Error with its status and code.
* @param {Response} res
* @returns {Promise<Error>}
*/
async function refusal(res) {
const answer = await res.json().catch(() => ({}));
return Object.assign(new Error(answer.error?.message ?? `HTTP ${res.status}`), {
status: res.status,
code: answer.error?.code,
});
}
/**
* A pause that ends early when `signal` aborts.
* @param {number} ms
* @param {AbortSignal} [signal]
* @returns {Promise<void>}
*/
function pauseFor(ms, signal) {
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", done);
resolve();
};
const timer = setTimeout(done, ms);
signal?.addEventListener("abort", done);
});
}
/**
* A value frozen with everything in it, so no write to the copy can change what the next update
* applies to. What is frozen already, an item no update touched, is left as it is.
* @template T
* @param {T} value
* @returns {T}
*/
function freeze(value) {
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
for (const inner of Object.values(value)) {
freeze(inner);
}
Object.freeze(value);
}
return value;
}
/**
* One connection's events, until the copy needs a fresh snapshot, the server ends the stream for
* this key, or the stream drops. `progress` says whether a snapshot came and how many updates were
* applied, whichever way the connection ends.
* @param {ReadableStream<Uint8Array>} body
* @param {(copy: Readonly<RoomCopy>) => void} onCopy
* @param {() => void} heard
* @param {{ connected: boolean, applied: number }} progress
* @returns {Promise<Outcome>}
*/
async function follow(body, onCopy, heard, progress) {
/** @type {RoomCopy | null} */
let copy = null;
/**
* The copy, frozen through and through, handed to `onCopy`; an error it throws is the caller's.
* @param {RoomCopy} next
* @returns {RoomCopy}
*/
const deliver = (next) => {
const frozen = freeze(next);
try {
onCopy(frozen);
} catch (error) {
throw { caller: error };
}
return frozen;
};
for await (const { event, data } of serverEvents(body)) {
heard();
if (event === "snapshot") {
const { revision, room, playback, timers, messages } = JSON.parse(data);
progress.connected = true;
copy = deliver({ revision, room, playback, timers, messages });
} else if (event === "update") {
const update = JSON.parse(data);
// Not the next revision: one was lost, or this one came twice.
/** @type {RoomCopy | null} */
const next =
copy !== null && update.revision === copy.revision + 1 ? applyUpdate(copy, update) : null;
if (next === null) {
return { end: "reopen" };
}
progress.applied += 1;
copy = deliver(next);
} else if (event === "heartbeat" && copy !== null) {
const beat = JSON.parse(data);
if (beat.revision > copy.revision) {
// The last update before a quiet spell was lost.
return { end: "reopen" };
}
const drifted =
beat.revision === copy.revision &&
beat.digest !== undefined &&
(beat.digest.timers !== listDigest(copy.timers) ||
beat.digest.messages !== listDigest(copy.messages));
if (drifted) {
return { end: "reopen" };
}
} else if (event === "kicked") {
return { end: "kicked", reason: JSON.parse(data).reason };
}
}
return { end: "dropped" };
}
Then follow a room with it:
import { followRoom } from "./key-stream.js";
const reason = await followRoom({
url: "https://app.cuestride.com/api/v1/rooms/K7QW3ND2XA/events",
key: process.env.CUESTRIDE_KEY,
onCopy: (room) => console.log(room.revision, room.timers.length),
});
console.log("The stream ended:", reason);
Every route
A string's length counts UTF-16 code units, as JavaScript's does, so a character outside the Basic Multilingual Plane, such as most emoji, counts as two.
Reads
GET /rooms · scope read
The rooms in the key's reach.
Answers 200 with the fields below; refuses with 400, 401, 403, 429.
| Answer field | Value | Notes |
|---|---|---|
ok (always) | true | |
rooms (always) | a list of RoomListItem |
GET /rooms/{roomId} · scope read
The room's settings.
Takes the If-None-Match header.
Answers 200 with the fields below, or 304 when If-None-Match holds the ETag of the room's current revision, the revision in quotes, such as "42"; refuses with 400, 401, 403, 404, 429.
| Answer field | Value | Notes |
|---|---|---|
ok (always) | true | |
revision (always) | a whole number of 0 or more | |
room (always) | RoomSettings |
GET /rooms/{roomId}/status · scope read
The room's status, for pollers.
Answers 200 with Status; refuses with 400, 401, 403, 404, 429.
GET /rooms/{roomId}/timers · scope read
The rundown, notes included. Like a write that sends the rundown, it counts as one read per 100 timers the room holds, at least one; a 304 counts as one.
Takes the If-None-Match header.
Answers 200 with the fields below, or 304 when If-None-Match holds the ETag of the room's current revision, the revision in quotes, such as "42"; refuses with 400, 401, 403, 404, 429.
| Answer field | Value | Notes |
|---|---|---|
ok (always) | true | |
revision (always) | a whole number of 0 or more | |
timers (always) | a list of Timer |
GET /rooms/{roomId}/messages · scope read
The messages. It counts as one read per 100 messages the room holds, at least one; a 304 counts as one.
Takes the If-None-Match header.
Answers 200 with the fields below, or 304 when If-None-Match holds the ETag of the room's current revision, the revision in quotes, such as "42"; refuses with 400, 401, 403, 404, 429.
| Answer field | Value | Notes |
|---|---|---|
ok (always) | true | |
revision (always) | a whole number of 0 or more | |
messages (always) | a list of Message |
The stream
GET /rooms/{roomId}/events · scope read
The room's moderator stream, attributed to the key: every message, timer notes blank. One per key per room; a new one replaces the key's old one there, which gets kicked with the reason replaced; 10 per key across rooms. A HEAD answers 405.
| Query | Value | Notes |
|---|---|---|
lists | text | items asks for the lists as item changes instead of whole, recovered by reopening the stream on a gap, a change that does not fit, or a heartbeat digest that differs. Without it, or with any other value, the lists come whole, and no value is refused. |
Answers 200 with server-sent events; refuses with 400, 401, 403, 404, 429.
Control commands
POST /rooms/{roomId}/playback/start or GET as a button link · scope control
Start the loaded timer.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/stop or GET as a button link · scope control
Stop the loaded timer.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/toggle or GET as a button link · scope control
Toggle the loaded timer.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/next or GET as a button link · scope control
Cue the next timer, and start it with autostart.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
autostart | true or false | |
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/previous or GET as a button link · scope control
Cue the previous timer, and start it with autostart.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
autostart | true or false | |
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/reset or GET as a button link · scope control
Reset the loaded timer, and start it with autostart.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
autostart | true or false | |
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/select or GET as a button link · scope control
Cue a timer by its id or its printed number, and start it with autostart. Give exactly one of timerId or index.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
timerId | text, 1 to 64 UTF-16 code units | |
index | a whole number from 1 to 10,000 | The number the controller prints. |
autostart | true or false | |
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/add or GET as a button link · scope control
Add time to the loaded timer. Give exactly one of ms or amount.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
ms | a whole number from 1 to 86,400,000 | |
amount | a whole number from 1 to 86,400 (seconds), or text, up to 32 UTF-16 code units | A duration: a whole number of seconds, or text such as 30s, 5m, 1.5h, 1m30s, 5:30, or 1:05:30, with no sign. Within 24 hours, and above zero, which text can break and the server refuses. In a query, send a + as %2B. |
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/subtract or GET as a button link · scope control
Take time from the loaded timer. Give exactly one of ms or amount.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
ms | a whole number from 1 to 86,400,000 | |
amount | a whole number from 1 to 86,400 (seconds), or text, up to 32 UTF-16 code units | A duration: a whole number of seconds, or text such as 30s, 5m, 1.5h, 1m30s, 5:30, or 1:05:30, with no sign. Within 24 hours, and above zero, which text can break and the server refuses. In a query, send a + as %2B. |
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/playback/jump or GET as a button link · scope control
Move the playhead forward or back. Give exactly one of ms or amount.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
ms | a whole number from -86,400,000 to 86,400,000, not 0 | |
amount | a whole number from -86,400 to 86,400, not 0 (seconds), or text, up to 32 UTF-16 code units | A duration: a whole number of seconds, or text such as 30s, 5m, 1.5h, 1m30s, 5:30, or 1:05:30, signed to move back: -10s. Within 24 hours, and not zero, which text can break and the server refuses. In a query, send a + as %2B. |
ifRevision | a whole number of 0 or more |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with PlaybackAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/blackout/on or GET as a button link · scope control
Turn blackout on.
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with RoomAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/blackout/off or GET as a button link · scope control
Turn blackout off.
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with RoomAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/blackout/toggle or GET as a button link · scope control
Turn blackout on or off.
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with RoomAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/flash or GET as a button link · scope control
Flash the screens or the message.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
count | a whole number from 1 to 999 | |
target | one of screen, message |
Answers 200 with FlashAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/messages/show or GET as a button link · scope control
Show a message, by its id or its place. Give exactly one of messageId or index. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
messageId | text, 1 to 64 UTF-16 code units | |
index | a whole number from 1 to 1,000 | The message's place in the controller's list. |
focus | true or false |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with MessagesAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/messages/toggle or GET as a button link · scope control
Show or hide a message, by its id or its place. Give exactly one of messageId or index. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
messageId | text, 1 to 64 UTF-16 code units | |
index | a whole number from 1 to 1,000 | The message's place in the controller's list. |
focus | true or false |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with MessagesAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/messages/hide or GET as a button link · scope control
Hide a message, by its id or its place. Give exactly one of messageId or index. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
| Parameter, in the body or the link's query | Value | Notes |
|---|---|---|
messageId | text, 1 to 64 UTF-16 code units | |
index | a whole number from 1 to 1,000 | The message's place in the controller's list. |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with MessagesAck; refuses with 400, 401, 403, 404, 409, 413, 429.
Edits
PATCH /rooms/{roomId} · scope edit
Change the room's settings.
| Body field | Value | Notes |
|---|---|---|
name | text, 1 to 200 UTF-16 code units | Trimmed before its length is checked. |
timezone | text | An IANA time zone name. |
clockFormat | one of 12h, 24h | |
defaults | an object | wrapUpYellowMs must be greater than or equal to wrapUpRedMs. |
defaults.wrapUpYellowMs (required) | a whole number from 0 to 86,400,000 | |
defaults.wrapUpRedMs (required) | a whole number from 0 to 86,400,000 | |
overtime | an object | Only continue and minus today; stop and plus are a later release. |
overtime.mode (required) | "continue" | |
overtime.sign (required) | "minus" |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with RoomAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/timers · scope edit
Add a timer. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
A timer's fields; a change sends only those it changes. On the timer as it would be: a finish-time timer needs finishAt, a time-warp timer presenterDurationMs, and a scheduled one scheduledStartAt; wrapUpYellowMs is at or above wrapUpRedMs; a scheduled start is no more than a day past, and a scheduled timer's day is its start's date in the room's time zone.
| Body field | Value | Notes |
|---|---|---|
name | text, up to 200 UTF-16 code units | Trimmed before its length is checked. |
speaker | text, up to 200 UTF-16 code units | Trimmed before its length is checked. |
notes | text, up to 5,000 UTF-16 code units | |
labels | a list of text, 1 to 32 UTF-16 code units, up to 8 | Each label trimmed before its length is checked, and kept once. |
durationMs | a whole number from 1,000 to 86,400,000 | |
type | one of DURATION, FINISH_TIME, TIME_WARP | |
finishAt | a whole number of 0 or more, or null | |
presenterDurationMs | a whole number from 1,000 to 86,400,000, or null | |
appearance | one of COUNTDOWN, COUNTUP, TOD, COUNTDOWN_TOD, COUNTUP_TOD, HIDDEN | |
wrapUpYellowMs | a whole number from 0 to 86,400,000 | |
wrapUpRedMs | a whole number from 0 to 86,400,000 | |
trigger | one of MANUAL, LINKED, SCHEDULED | |
scheduledStartAt | a whole number of 0 or more, or null | |
day | text, or null | A calendar date, YYYY-MM-DD. |
actions | an object | |
actions.start | an object | |
actions.start.chime | one of none, soft, bell, alarm | |
actions.start.flash | a whole number from 0 to 5 | |
actions.yellow | an object | |
actions.yellow.chime | one of none, soft, bell, alarm | |
actions.yellow.flash | a whole number from 0 to 5 | |
actions.red | an object | |
actions.red.chime | one of none, soft, bell, alarm | |
actions.red.flash | a whole number from 0 to 5 | |
actions.zero | an object | |
actions.zero.chime | one of none, soft, bell, alarm | |
actions.zero.flash | a whole number from 0 to 5 | |
position | a whole number from 0 to 500 |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with TimersAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/messages · scope edit
Add a message. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
| Body field | Value | Notes |
|---|---|---|
text | text, up to 1,000 UTF-16 code units | |
color | one of white, green, red | |
bold | true or false | |
uppercase | true or false | |
position | a whole number from 0 to 500 |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with MessagesAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/timers/reorder · scope edit
Put the rundown in a new order. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
| Body field | Value | Notes |
|---|---|---|
ids (required) | a list of text, 1 to 64 UTF-16 code units, 1 to 500 of them | Every id of the list, each once, in the new order. |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with TimersAck; refuses with 400, 401, 403, 404, 409, 413, 429.
PATCH /rooms/{roomId}/timers/{timerId} · scope edit
Change a timer. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
A timer's fields; a change sends only those it changes. On the timer as it would be: a finish-time timer needs finishAt, a time-warp timer presenterDurationMs, and a scheduled one scheduledStartAt; wrapUpYellowMs is at or above wrapUpRedMs; a scheduled start is no more than a day past, and a scheduled timer's day is its start's date in the room's time zone.
| Body field | Value | Notes |
|---|---|---|
name | text, up to 200 UTF-16 code units | Trimmed before its length is checked. |
speaker | text, up to 200 UTF-16 code units | Trimmed before its length is checked. |
notes | text, up to 5,000 UTF-16 code units | |
labels | a list of text, 1 to 32 UTF-16 code units, up to 8 | Each label trimmed before its length is checked, and kept once. |
durationMs | a whole number from 1,000 to 86,400,000 | |
type | one of DURATION, FINISH_TIME, TIME_WARP | |
finishAt | a whole number of 0 or more, or null | |
presenterDurationMs | a whole number from 1,000 to 86,400,000, or null | |
appearance | one of COUNTDOWN, COUNTUP, TOD, COUNTDOWN_TOD, COUNTUP_TOD, HIDDEN | |
wrapUpYellowMs | a whole number from 0 to 86,400,000 | |
wrapUpRedMs | a whole number from 0 to 86,400,000 | |
trigger | one of MANUAL, LINKED, SCHEDULED | |
scheduledStartAt | a whole number of 0 or more, or null | |
day | text, or null | A calendar date, YYYY-MM-DD. |
actions | an object | |
actions.start | an object | |
actions.start.chime | one of none, soft, bell, alarm | |
actions.start.flash | a whole number from 0 to 5 | |
actions.yellow | an object | |
actions.yellow.chime | one of none, soft, bell, alarm | |
actions.yellow.flash | a whole number from 0 to 5 | |
actions.red | an object | |
actions.red.chime | one of none, soft, bell, alarm | |
actions.red.flash | a whole number from 0 to 5 | |
actions.zero | an object | |
actions.zero.chime | one of none, soft, bell, alarm | |
actions.zero.flash | a whole number from 0 to 5 | |
position | a whole number from 0 to 500 |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with TimersAck; refuses with 400, 401, 403, 404, 409, 413, 429.
DELETE /rooms/{roomId}/timers/{timerId} · scope edit
Delete a timer. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with TimersAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/timers/{timerId}/duplicate · scope edit
Duplicate a timer, next to it. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
A JSON object whose fields are ignored; it still counts in a command id's identity.
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with TimersAck; refuses with 400, 401, 403, 404, 409, 413, 429.
POST /rooms/{roomId}/messages/reorder · scope edit
Put the messages in a new order. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
| Body field | Value | Notes |
|---|---|---|
ids (required) | a list of text, 1 to 64 UTF-16 code units, 1 to 500 of them | Every id of the list, each once, in the new order. |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with MessagesAck; refuses with 400, 401, 403, 404, 409, 413, 429.
PATCH /rooms/{roomId}/messages/{messageId} · scope edit
Change a message. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
| Body field | Value | Notes |
|---|---|---|
text | text, up to 1,000 UTF-16 code units | |
color | one of white, green, red | |
bold | true or false | |
uppercase | true or false | |
position | a whole number from 0 to 500 |
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with MessagesAck; refuses with 400, 401, 403, 404, 409, 413, 429.
DELETE /rooms/{roomId}/messages/{messageId} · scope edit
Delete a message. A write that changes the rundown or the messages counts as one write per 100 items the room holds, at least one.
Takes the Command-Id and Idempotency-Key headers.
Answers 200 with MessagesAck; refuses with 400, 401, 403, 404, 409, 413, 429.
This description
What the answers hold
Status
The loaded timer and the one next would cue, with the times the presenter sees, the phase, blackout, and the message showing. Always 200: its times move at the same revision.
| Field | Value | Notes |
|---|---|---|
ok (always) | true | |
revision (always) | a whole number of 0 or more | |
serverTime (always) | a whole number of 0 or more | |
room (always) | an object | |
room.id (always) | text | |
room.name (always) | text | |
room.blackout (always) | true or false | |
timer (always) | TimerRef, or null | |
next (always) | NextTimer, or null | |
running (always) | true or false | |
phase (always) | one of green, yellow, red, overtime, idle | |
remainingMs (always) | a whole number, or null | |
elapsedMs (always) | a whole number, or null | |
totalMs (always) | a whole number, or null | |
message (always) | an object, or null | |
message.id (always) | a UUID | |
message.text (always) | text | |
message.color (always) | one of white, green, red | |
message.focus (always) | true or false |
TimerRef
| Field | Value | Notes |
|---|---|---|
id (always) | a UUID | |
index (always) | a whole number of 1 or more | The number the controller prints. |
name (always) | text | |
speaker (always) | text |
NextTimer
| Field | Value | Notes |
|---|---|---|
id (always) | a UUID | |
index (always) | a whole number of 1 or more | |
name (always) | text | |
speaker (always) | text | |
disabled (always) | true or false | The plan disables the timer next would cue. |
RoomListItem
| Field | Value | Notes |
|---|---|---|
id (always) | text | |
name (always) | text | |
disabled (always) | true or false | The plan disables the room. |
apiOff (always) | true or false | The room's controller turned the API off. |
RoomSettings
| Field | Value | Notes |
|---|---|---|
id (always) | text | |
name (always) | text | |
timezone (always) | text | An IANA time zone name. |
clockFormat (always) | one of 12h, 24h | |
blackout (always) | true or false | |
messageFocus (always) | true or false | |
defaults (always) | an object | |
defaults.wrapUpYellowMs (always) | a whole number from 0 to 86,400,000 | |
defaults.wrapUpRedMs (always) | a whole number from 0 to 86,400,000 | |
overtime (always) | an object | |
overtime.mode (always) | one of continue, stop | |
overtime.sign (always) | one of minus, plus |
Timer
| Field | Value | Notes |
|---|---|---|
id (always) | a UUID | |
roomId (always) | text | |
position (always) | a whole number of 0 or more | |
name (always) | text | |
speaker (always) | text | |
notes (always) | text | |
durationMs (always) | a whole number from 1,000 to 86,400,000 | |
appearance (always) | one of COUNTDOWN, COUNTUP, TOD, COUNTDOWN_TOD, COUNTUP_TOD, HIDDEN | |
type (always) | one of DURATION, FINISH_TIME, TIME_WARP | |
finishAt (always) | a whole number, or null | |
presenterDurationMs (always) | a whole number, or null | |
trigger (always) | one of MANUAL, LINKED, SCHEDULED | |
scheduledStartAt (always) | a whole number, or null | |
day (always) | text, or null | |
wrapUpYellowMs (always) | a whole number from 0 to 86,400,000 | |
wrapUpRedMs (always) | a whole number from 0 to 86,400,000 | |
labels (always) | a list of text, up to 8 | |
actions (always) | an object | |
actions.start (always) | an object | |
actions.start.chime (always) | one of none, soft, bell, alarm | |
actions.start.flash (always) | a whole number from 0 to 5 | |
actions.yellow (always) | an object | |
actions.yellow.chime (always) | one of none, soft, bell, alarm | |
actions.yellow.flash (always) | a whole number from 0 to 5 | |
actions.red (always) | an object | |
actions.red.chime (always) | one of none, soft, bell, alarm | |
actions.red.flash (always) | a whole number from 0 to 5 | |
actions.zero (always) | an object | |
actions.zero.chime (always) | one of none, soft, bell, alarm | |
actions.zero.flash (always) | a whole number from 0 to 5 | |
createdAt (always) | a whole number of 0 or more | |
updatedAt (always) | a whole number of 0 or more |
Message
| Field | Value | Notes |
|---|---|---|
id (always) | a UUID | |
roomId (always) | text | |
position (always) | a whole number of 0 or more | |
text (always) | text | |
color (always) | one of white, green, red | |
bold (always) | true or false | |
uppercase (always) | true or false | |
showing (always) | true or false | |
createdAt (always) | a whole number of 0 or more | |
updatedAt (always) | a whole number of 0 or more |
Playback
| Field | Value | Notes |
|---|---|---|
timerId (always) | text, or null | |
running (always) | true or false | |
startAt (always) | a whole number, or null | |
finishAt (always) | a whole number, or null | |
pauseAt (always) | a whole number, or null |
Room
The room as a write's acknowledgement carries it.
| Field | Value | Notes |
|---|---|---|
id (always) | text | |
name (always) | text | |
timezone (always) | text | |
clockFormat (always) | one of 12h, 24h | |
blackout (always) | true or false | |
onAir (always) | true or false | |
messageFocus (always) | true or false | |
defaults (always) | an object | |
defaults.wrapUpYellowMs (always) | a whole number from 0 to 86,400,000 | |
defaults.wrapUpRedMs (always) | a whole number from 0 to 86,400,000 | |
overtime (always) | an object | |
overtime.mode (always) | one of continue, stop | |
overtime.sign (always) | one of minus, plus | |
eventId (always) | text, or null | |
revision (always) | a whole number of 0 or more | |
lastActivityAt (always) | a whole number of 0 or more | |
createdAt (always) | a whole number of 0 or more | |
updatedAt (always) | a whole number of 0 or more |
PlaybackAck
Its section is always there but on a replay too large to send again.
| Field | Value | Notes |
|---|---|---|
ok (always) | true | |
changed (always) | true or false | False when the command changed nothing. |
revision (always) | a whole number of 0 or more | |
playback | Playback | |
replayed | true | A repeat of a command id, answered from its receipt. |
truncated | true | A replayed answer over 64 KB: the envelope alone, without its section. |
RoomAck
Its section is always there but on a replay too large to send again.
| Field | Value | Notes |
|---|---|---|
ok (always) | true | |
changed (always) | true or false | False when the command changed nothing. |
revision (always) | a whole number of 0 or more | |
room | Room | |
replayed | true | A repeat of a command id, answered from its receipt. |
truncated | true | A replayed answer over 64 KB: the envelope alone, without its section. |
TimersAck
Its section is always there but on a replay too large to send again.
| Field | Value | Notes |
|---|---|---|
ok (always) | true | |
changed (always) | true or false | False when the command changed nothing. |
revision (always) | a whole number of 0 or more | |
timers | a list of Timer | |
playback | Playback | |
replayed | true | A repeat of a command id, answered from its receipt. |
truncated | true | A replayed answer over 64 KB: the envelope alone, without its section. |
MessagesAck
Its section is always there but on a replay too large to send again.
| Field | Value | Notes |
|---|---|---|
ok (always) | true | |
changed (always) | true or false | False when the command changed nothing. |
revision (always) | a whole number of 0 or more | |
messages | a list of Message | |
room | Room | |
replayed | true | A repeat of a command id, answered from its receipt. |
truncated | true | A replayed answer over 64 KB: the envelope alone, without its section. |
FlashAck
| Field | Value | Notes |
|---|---|---|
ok (always) | true |
Error
Every refusal: a code, a message, and for some codes their numbers.
| Field | Value | Notes |
|---|---|---|
ok (always) | false | |
error (always) | an object | |
error.code (always) | text | |
error.message (always) | text | |
error.limit | a whole number, or null | |
error.used | a whole number | |
error.plan | text |
Changes to the API
Within v1, changes only add: new routes, new optional fields, and new codes, never a field
removed or its meaning changed. A change that breaks a tool would be a v2, and v1 would keep
answering for at least six months after it.