CueStrideSign up free
Docs menu

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:

NameInValueNotes
roomIdpathtextThe 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.
timerIdpatha UUID
messageIdpatha UUID
Command-Idheadera UUIDA 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-Keyheadera UUIDThe same as Command-Id; both, with different ids, answer 400 VALIDATION. Flash reads neither.
If-None-MatchheadertextThe 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:

WhatA minuteAt once
A key's status reads300300
A key's other reads, its stream's opening among them6060
A key's control commands and edits12030
The same, for a key of an account on Starter3030
Every read of an account's keys together6,0002,000
Every write of an account's keys together30060
Every write of every key together1,200120
Requests with no key or an unknown one, per address60120
Requests with a revoked or expired key, per address and key612
Keys an account's admins make and replace3030

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"}}:

StatusCodesWhen
400VALIDATION, INVALID_AMOUNT, KEY_IN_URL, KEY_CONFLICTVALIDATION, 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).
401KEY_REQUIRED, KEY_INVALIDKEY_REQUIRED, or KEY_INVALID: unknown, revoked, or expired.
403SCOPE_FORBIDDEN, KEY_DISABLED, ROOM_DISABLED, API_OFF, NOT_A_PRESS, PLAN_LIMIT, TIMER_DISABLED, COMMAND_ID_FOREIGNSCOPE_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).
404ROOM_NOT_FOUND, TIMER_NOT_FOUND, MESSAGE_NOT_FOUND, NOT_FOUNDROOM_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).
409REVISION_CONFLICT, COMMAND_ID_REUSED, LIMIT_REACHEDREVISION_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).
413TOO_LARGETOO_LARGE: a body over 64 KB.
429RATE_LIMITED, STREAM_LIMITRATE_LIMITED, or STREAM_LIMIT: wait for Retry-After.
500INTERNALA 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 fieldValueNotes
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 fieldValueNotes
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 fieldValueNotes
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 fieldValueNotes
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.

QueryValueNotes
liststextitems 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 queryValueNotes
ifRevisiona 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 queryValueNotes
ifRevisiona 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 queryValueNotes
ifRevisiona 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 queryValueNotes
autostarttrue or false
ifRevisiona 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 queryValueNotes
autostarttrue or false
ifRevisiona 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 queryValueNotes
autostarttrue or false
ifRevisiona 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 queryValueNotes
timerIdtext, 1 to 64 UTF-16 code units
indexa whole number from 1 to 10,000The number the controller prints.
autostarttrue or false
ifRevisiona 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 queryValueNotes
msa whole number from 1 to 86,400,000
amounta whole number from 1 to 86,400 (seconds), or text, up to 32 UTF-16 code unitsA 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.
ifRevisiona 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 queryValueNotes
msa whole number from 1 to 86,400,000
amounta whole number from 1 to 86,400 (seconds), or text, up to 32 UTF-16 code unitsA 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.
ifRevisiona 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 queryValueNotes
msa whole number from -86,400,000 to 86,400,000, not 0
amounta whole number from -86,400 to 86,400, not 0 (seconds), or text, up to 32 UTF-16 code unitsA 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.
ifRevisiona 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 queryValueNotes
counta whole number from 1 to 999
targetone 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 queryValueNotes
messageIdtext, 1 to 64 UTF-16 code units
indexa whole number from 1 to 1,000The message's place in the controller's list.
focustrue 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 queryValueNotes
messageIdtext, 1 to 64 UTF-16 code units
indexa whole number from 1 to 1,000The message's place in the controller's list.
focustrue 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 queryValueNotes
messageIdtext, 1 to 64 UTF-16 code units
indexa whole number from 1 to 1,000The 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 fieldValueNotes
nametext, 1 to 200 UTF-16 code unitsTrimmed before its length is checked.
timezonetextAn IANA time zone name.
clockFormatone of 12h, 24h
defaultsan objectwrapUpYellowMs 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
overtimean objectOnly 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 fieldValueNotes
nametext, up to 200 UTF-16 code unitsTrimmed before its length is checked.
speakertext, up to 200 UTF-16 code unitsTrimmed before its length is checked.
notestext, up to 5,000 UTF-16 code units
labelsa list of text, 1 to 32 UTF-16 code units, up to 8Each label trimmed before its length is checked, and kept once.
durationMsa whole number from 1,000 to 86,400,000
typeone of DURATION, FINISH_TIME, TIME_WARP
finishAta whole number of 0 or more, or null
presenterDurationMsa whole number from 1,000 to 86,400,000, or null
appearanceone of COUNTDOWN, COUNTUP, TOD, COUNTDOWN_TOD, COUNTUP_TOD, HIDDEN
wrapUpYellowMsa whole number from 0 to 86,400,000
wrapUpRedMsa whole number from 0 to 86,400,000
triggerone of MANUAL, LINKED, SCHEDULED
scheduledStartAta whole number of 0 or more, or null
daytext, or nullA calendar date, YYYY-MM-DD.
actionsan object
actions.startan object
actions.start.chimeone of none, soft, bell, alarm
actions.start.flasha whole number from 0 to 5
actions.yellowan object
actions.yellow.chimeone of none, soft, bell, alarm
actions.yellow.flasha whole number from 0 to 5
actions.redan object
actions.red.chimeone of none, soft, bell, alarm
actions.red.flasha whole number from 0 to 5
actions.zeroan object
actions.zero.chimeone of none, soft, bell, alarm
actions.zero.flasha whole number from 0 to 5
positiona 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 fieldValueNotes
texttext, up to 1,000 UTF-16 code units
colorone of white, green, red
boldtrue or false
uppercasetrue or false
positiona 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 fieldValueNotes
ids (required)a list of text, 1 to 64 UTF-16 code units, 1 to 500 of themEvery 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 fieldValueNotes
nametext, up to 200 UTF-16 code unitsTrimmed before its length is checked.
speakertext, up to 200 UTF-16 code unitsTrimmed before its length is checked.
notestext, up to 5,000 UTF-16 code units
labelsa list of text, 1 to 32 UTF-16 code units, up to 8Each label trimmed before its length is checked, and kept once.
durationMsa whole number from 1,000 to 86,400,000
typeone of DURATION, FINISH_TIME, TIME_WARP
finishAta whole number of 0 or more, or null
presenterDurationMsa whole number from 1,000 to 86,400,000, or null
appearanceone of COUNTDOWN, COUNTUP, TOD, COUNTDOWN_TOD, COUNTUP_TOD, HIDDEN
wrapUpYellowMsa whole number from 0 to 86,400,000
wrapUpRedMsa whole number from 0 to 86,400,000
triggerone of MANUAL, LINKED, SCHEDULED
scheduledStartAta whole number of 0 or more, or null
daytext, or nullA calendar date, YYYY-MM-DD.
actionsan object
actions.startan object
actions.start.chimeone of none, soft, bell, alarm
actions.start.flasha whole number from 0 to 5
actions.yellowan object
actions.yellow.chimeone of none, soft, bell, alarm
actions.yellow.flasha whole number from 0 to 5
actions.redan object
actions.red.chimeone of none, soft, bell, alarm
actions.red.flasha whole number from 0 to 5
actions.zeroan object
actions.zero.chimeone of none, soft, bell, alarm
actions.zero.flasha whole number from 0 to 5
positiona 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 fieldValueNotes
ids (required)a list of text, 1 to 64 UTF-16 code units, 1 to 500 of themEvery 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 fieldValueNotes
texttext, up to 1,000 UTF-16 code units
colorone of white, green, red
boldtrue or false
uppercasetrue or false
positiona 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

GET /openapi.json

This description. It needs no key, and a key left in its query is refused, as on every route but a button link and the stream.

Answers 200 with this API's OpenAPI description; refuses with 400, 429.

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.

FieldValueNotes
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

FieldValueNotes
id (always)a UUID
index (always)a whole number of 1 or moreThe number the controller prints.
name (always)text
speaker (always)text

NextTimer

FieldValueNotes
id (always)a UUID
index (always)a whole number of 1 or more
name (always)text
speaker (always)text
disabled (always)true or falseThe plan disables the timer next would cue.

RoomListItem

FieldValueNotes
id (always)text
name (always)text
disabled (always)true or falseThe plan disables the room.
apiOff (always)true or falseThe room's controller turned the API off.

RoomSettings

FieldValueNotes
id (always)text
name (always)text
timezone (always)textAn 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

FieldValueNotes
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

FieldValueNotes
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

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

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

FieldValueNotes
ok (always)true
changed (always)true or falseFalse when the command changed nothing.
revision (always)a whole number of 0 or more
playbackPlayback
replayedtrueA repeat of a command id, answered from its receipt.
truncatedtrueA 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.

FieldValueNotes
ok (always)true
changed (always)true or falseFalse when the command changed nothing.
revision (always)a whole number of 0 or more
roomRoom
replayedtrueA repeat of a command id, answered from its receipt.
truncatedtrueA 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.

FieldValueNotes
ok (always)true
changed (always)true or falseFalse when the command changed nothing.
revision (always)a whole number of 0 or more
timersa list of Timer
playbackPlayback
replayedtrueA repeat of a command id, answered from its receipt.
truncatedtrueA 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.

FieldValueNotes
ok (always)true
changed (always)true or falseFalse when the command changed nothing.
revision (always)a whole number of 0 or more
messagesa list of Message
roomRoom
replayedtrueA repeat of a command id, answered from its receipt.
truncatedtrueA replayed answer over 64 KB: the envelope alone, without its section.

FlashAck

FieldValueNotes
ok (always)true

Error

Every refusal: a code, a message, and for some codes their numbers.

FieldValueNotes
ok (always)false
error (always)an object
error.code (always)text
error.message (always)text
error.limita whole number, or null
error.useda whole number
error.plantext

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.

Your next show, on time.

Put a room's link on a screen and run the show; sign up free when you want to keep it.