Skip to content

HTTP REST API ​

Exaplay 3 exposes an HTTP REST API for programmatic control of compositions, Playlists, Timelines, and media playback. This is the same API used by the Exaplay web UI and is suitable for integration with external show control systems, custom applications, and automation scripts.

Naming note: The Exaplay UI calls this composition type a Playlist. The wire protocol still uses the original cuelist identifier (composition type string, request names such as cuelist.seek, status fields such as CUEINDEX). This is intentional so existing integrations continue to work — treat cuelist and CueList in the protocol as synonyms for Playlist in the UI.

Connection ​

ParameterValue
ProtocolHTTP
Default Port8123
Content-Typeapplication/json

Requests and responses use JSON. Most control verbs are sent as POST /project with a req field that names the verb, but that is not the only endpoint — see the index below.

Responses. A bare success is 200 OK with an empty body; some verbs (noted below) return JSON. Error responses from the engine usually have an empty body too, so read the HTTP status rather than expecting a message. Every response carries Access-Control-Allow-Origin: *, and an OPTIONS pre-flight is answered 200, so a page served from another origin can call the engine directly.

Listen address. The engine listens on the address and port configured in its settings (default port 8123). A configured address that cannot be bound yet is retried every 5 s for up to five minutes; only when no listen line is configured at all does it serve on localhost:80.

Security ​

The HTTP port has no authentication. Anyone who can reach it can control the show, read the project and use every function of the web UI. Keep Exaplay on an isolated show network and firewall port 8123 from every other network — see Network configuration.

Endpoint index ​

Method & pathPurpose
POST /projectTransport, cue and project verbs, selected by the req field — see Composition Transport, Cue Commands, Media Commands, Project Commands
GET /project?req=treeThe whole project as JSON (see Reading the project)
GET /project?req=plainThe project as a plain-text list of objects, UIDs and variable names
POST /data, GET /dataWrite and read any object's properties — see Properties — /data
POST /cue/triggerFire a cue by name or number across all compositions — see below
POST /composition/playStart a composition by name or UID — see below
POST /stopStop all compositions. No body. 200, 400 when no project is loaded, 503 while a project loads
POST /control/firePress a command button — see below
POST /cmdProjector control (pjlink, pjlink-all, pjlink-poll) and TCP devices (tcp) — see Projector control — /cmd
POST /cmdDALI lighting (dali) — see DALI lighting — /cmd
GET /tracking, POST /trackingFeed and read Inputs channels — see Inputs. POST /tracking {"command": "solo=scene2"} switches Inputs groups with the text of a cue's TRACKING> line (without the prefix)
GET /streams, GET /stream/mjpegLive MJPEG preview of an output — see Experience Designer
ws://<host>:8123/infoWebSocket status feed: a complete JSON frame whenever something in it changed (checked every 50 ms), and at least one a second while nothing does. It used to be a frame every 50 ms regardless, which flooded a slow external application until it stalled; a frame is only ever sent when it differs from the last one you got, so read state off each frame and never count frames. This is the feed Exaplay's own UI uses; its schema is not yet documented and may change. For an integration, use ws://<host>:8123/status instead — a small documented view, pushed on change. /info?v=2 asks for the sliced form the UI reads: a flat message that carries media, comp, audio_meter and state (everything else) only when they changed since your last message, plus the clock and task — and nothing on a tick where nothing changed: while the show stands still you receive one short message a second (the clock alone), so a change reaches you within a tick and silence costs you nothing. Every "when" in the frame (an Inputs channel's seen, a projector verdict's at) is a time on the frame's own time clock, never an age. A text frame {"rate":1} sent on either form slows your socket to that many frames per second (1–20)
GET /custom/… and other static pathsStatic files, including your own custom web interfaces

Other routes exist for Exaplay's own UI and for multi-client fleet operation. They are not a stable API and are not documented here.

Watching your own calls arrive ​

Open the Monitor while you integrate. Every call below is logged twice — once as it arrives, with your IP address and the verb it carried, and once with the HTTP status it was answered with. A command that reaches the wrong composition, or is refused by a read-only client, says so there rather than simply doing nothing. The Monitor's Listeners card also reports which addresses and ports this engine is really bound to, which is the other half of "my request is not getting through", and its Send a test command box makes the engine put an OSC, TCP or UDP line through its own socket so you can check a command before your controller is on site. The log can be downloaded as text or JSON to go with a support case.


Composition Transport ​

These commands control playback of any composition (Playlist or Timeline). UIDs come from GET /project?req=tree (see Reading the project).

All transport commands are sent as POST /project requests with the req field. For composition.play there is also a convenience shortcut — see below. An unknown UID answers 404.

req valueRequired fieldsDescription
composition.playsrc-uidStart playback
composition.pausesrc-uidPause playback
composition.stopsrc-uidStop playback
composition.restartsrc-uidPlay from start: a Playlist's current item back to its start, playing on (a looped clip starts over without stopping); a Timeline from 0. Paused or stopped, it starts
composition.set-timesrc-uid, timeSeek to position (seconds) — see below
composition.nextsrc-uidAdvance to the next cue/item (Playlist only) — see below
composition.prevsrc-uidGo back to the previous cue/item (Playlist only)
composition.showsrc-uid, values: {"show": true|false}Show or hide the composition's picture (always 200)

composition.set-time:

  • Timeline — time is the absolute position in seconds.
  • Playlist — time is seconds within the current item, not from the start of the Playlist.
  • A stopped composition is put into pause at the new position, so the frame shows on the output without starting playback.
  • 404 if the UID is not a composition.

Example: Seek a composition to 30 seconds ​

http
POST /project HTTP/1.1
Content-Type: application/json

{
  "req": "composition.set-time",
  "src-uid": 12345678,
  "time": 30.0
}

Response: 200 OK

Play by name — POST /composition/play ​

A convenience endpoint that locates a composition by name (or UID) and starts playback without requiring an upfront GET /project lookup.

Fields:

FieldTypeDescription
namestringName of the composition to play
src-uidintegerUID of the composition (alternative to name)

Provide either name or src-uid. If both are present, the first composition matching either value is played. The name match is exact and case-sensitive (unlike /cue/trigger, which ignores case), and a composition's variable name (comp1) is not accepted here — use src-uid, or /cue/trigger with comp.

http
POST /composition/play HTTP/1.1
Content-Type: application/json

{
  "name": "Main Show"
}

Response: 200 OK — composition found and started Response: 400 Bad Request — neither name nor src-uid provided Response: 404 Not Found — no composition matches the given name or UID Response: 503 — a project is loading


Cue Commands ​

Trigger a cue in a specific composition — cue.go ​

Triggers a cue inside one specific composition, addressed by the composition's UID. This is the endpoint to use when several compositions contain cues with the same name and /cue/trigger (below) would be ambiguous.

req valueRequired fieldsDescription
cue.gocomp-uid, cue or nameTrigger a cue by index or name in the given composition
cue.executecomp-uid, cue-uidTrigger a Timeline cue by its UID
cue.nextcomp-uidAdvance to the next item (Playlist only)
cue.prevcomp-uidGo back to the previous item (Playlist only)

Fields:

FieldTypeDescription
comp-uidintegerThe UID of the target composition (from GET /project)
cueintegerTimeline: the cue's variable index · Playlist: the 1-based item position
namestringCue/item name (case-insensitive, within this composition) — alternative to cue. For a Playlist this is the item's name. A purely numeric name is treated as an index
cue-uidintegerThe cue object's UID (cue.execute only)

Behaviour of cue.go:

  • Timeline — the playhead jumps to the cue's time offset, then the cue's action (play/pause/stop/custom) is executed.
  • Playlist — the item at the given 1-based position starts playing. cue: 1 also starts a stopped Playlist.
  • If cue is greater than 0 it wins over name.
  • 404 if comp-uid is not a composition; 500 if no cue matches cue/name, or the trigger failed.

cue.execute takes the UID of a Timeline cue object. 404 if the composition or the cue is not found. For Playlist items use cue.go or media.play.

cue.next / cue.prev answer 404 when the composition is not a Playlist or has no current (playing or armed) item. To start a stopped Playlist, send cue.go with "cue": 1.

composition.next / composition.prev do the same, but answer 200 (and do nothing) when the Playlist has no current item or no next/previous item; they answer 404 only when the UID is not a Playlist.

Example: Trigger cue 5 in composition UID 12345678 ​

http
POST /project HTTP/1.1
Content-Type: application/json

{
  "req": "cue.go",
  "comp-uid": 12345678,
  "cue": 5
}

Example: Trigger the cue named "Blackout" in composition UID 12345678 ​

http
POST /project HTTP/1.1
Content-Type: application/json

{
  "req": "cue.go",
  "comp-uid": 12345678,
  "name": "Blackout"
}

Response: 200 OK — see the cue.go behaviour above for 404 / 500.

Trigger a cue by name across all compositions — POST /cue/trigger ​

This is a separate endpoint (not a req on /project): POST http://<host>:8123/cue/trigger. It searches all compositions in project order — Timeline cues by name or variable index, Playlist items by name or variable index — and fires the first match.

Body fieldTypeDescription
namestringCue/item name to match (case-insensitive)
numberintegerCue variable index to match (alternative to name)
compstring(optional) Restrict the search to the composition with this variable name
comp-uidinteger(optional) Restrict the search to the composition with this UID
bash
curl -X POST http://192.168.1.5:8123/cue/trigger \
  -H "Content-Type: application/json" \
  -d '{"name": "Scene 1 - Opening"}'

Scoped to one composition — with comp (or comp-uid) only that composition is searched, so identically named cues elsewhere can never fire:

bash
curl -X POST http://192.168.1.5:8123/cue/trigger \
  -H "Content-Type: application/json" \
  -d '{"name": "Blackout", "comp": "comp_act2"}'

Response: 200 OK on the first match, 404 if no cue matched (or the scoped composition does not exist), 400 if neither name nor number was supplied or the body is not valid JSON, 503 while a project loads.

Duplicate cue names: without a scope the first match wins, so either keep cue names project-unique or pass comp/comp-uid. Alternatively use cue.go with comp-uid + name.

Press a command button — POST /control/fire ​

Runs the commands of a command button from the Control tab. The body names the button by its id, which the Control tab's Inspector shows as ID when the button is selected. The commands themselves are always the ones stored in the project. A request cannot send commands of its own.

bash
curl -X POST http://192.168.1.5:8123/control/fire \
  -H "Content-Type: application/json" \
  -d '{"id": "control_script_1727800000000"}'

Response: 200 with {"ok": true, "lines": 3} when the button started. {"ok": false, "reason": "…"} when it did not: the project has no button with this id, the button has no commands, it is still running from an earlier press, or this machine is a multi-client client (the master runs command buttons). 400 for a missing or malformed id, 503 while a project is loading.


Playlist Commands ​

Seek within a Playlist ​

Seek the current item of a Playlist to a position in seconds, within that item. This is the same seek as composition.set-time, but it validates that the target is a Playlist: it answers 400 Bad Request if the composition is not a Playlist and 404 if the UID is not a composition. Unlike composition.set-time it does not take a stopped Playlist out of stop.

For seeking any composition type (Playlist or Timeline), use composition.set-time instead.

req valueRequired fieldsDescription
cuelist.seeksrc-uid, timeSeek the current Playlist item to position (seconds)

Fields:

FieldTypeDescription
src-uidintegerThe UID of the Playlist composition
timefloatTarget position in seconds

Example: Seek a Playlist to 15.5 seconds ​

http
POST /project HTTP/1.1
Content-Type: application/json

{
  "req": "cuelist.seek",
  "src-uid": 12345678,
  "time": 15.5
}

Response: 200 OK


Media Commands ​

req valueRequired fieldsDescription
media.set-timesrc-uid, timeSeek to a position (seconds) through a media item
media.playsrc-uidPlay this Playlist item. 400 if the UID is not a clip in a Playlist
media.restartsrc-uidPlay this Playlist item from its start: the playing item goes back to its start and plays on (a looped clip starts over without stopping), any other item starts. 400 if the UID is not a clip in a Playlist
media.showsrc-uid, values: {"show": true|false}Show or hide a clip (always 200)
group.showsrc-uid, values: {"show": true|false}Show or hide a group. 400 if the UID is not a group
track.showsrc-uid, values: {"show": true|false}Show or hide a track. 400 if the UID is not a track
data-track.showsrc-uid, values: {"show": true|false}Show or hide a Data Track. 400 if the UID is not a Data Track

Seek a specific media item ​

Seek through a specific media item by its UID. This is useful when you know the exact item (e.g. from a Playlist item click).

  • In a Playlist, time is local to the item (0 … item duration).
  • A stopped composition is put into pause at the new position so the frame shows.
  • An unknown media UID currently answers 501.

Fields:

FieldTypeDescription
src-uidintegerThe UID of the media item
timefloatTarget position in seconds

Example: Seek a media item to 10 seconds ​

http
POST /project HTTP/1.1
Content-Type: application/json

{
  "req": "media.set-time",
  "src-uid": 87654321,
  "time": 10.0
}

Response: 200 OK

Example: Hide a clip ​

http
POST /project HTTP/1.1
Content-Type: application/json

{
  "req": "media.show",
  "src-uid": 87654321,
  "values": { "show": false }
}

Project Commands ​

req valueFieldsDescription
project.loadpath, tolerant (optional)Load a project file. 200 with the project JSON on success
project.savepath (optional)Save the project; 200 with the saved project as JSON. Without path it saves to the project's own file (a never-saved project to default.vpp in the engine's work folder); a bare file name without a folder is saved in the work folder
project.new—Start an empty project

project.load answers:

  • 200 — the loaded project as JSON.
  • 401 — {"message":"Project file not found"} (no such file) or {"message":"Cannot open project"} (the file exists but cannot be opened).
  • 422 — the project was loaded, but it refers to missing or unloadable media: {"message", "missing_files": [...], "unloadable_files": [...], "modified_path", "original_path"}. It now saves to modified_path (the file name with _missingfiles added), so the original file is not overwritten. With "tolerant": true the same load answers 200 and keeps the original path.
  • 500 — the load failed.

The structural verbs the editor uses (adding, moving, deleting objects and so on) are also carried by POST /project. They are not listed here; for scripted property changes use /data.


Reading the project ​

GET /project?req=tree returns the whole project as JSON. Each object carries _uid, _varname, _type, _base-type, _class, its properties in values{…} and its children in elements[…]. Walk it to find the UIDs the verbs above need.

  • GET /project?req=plain returns a plain-text list (type, UID, variable name) — handy for a quick look in a browser.
  • Any other req answers 400.
  • While a project is loading, the answer is 200 {"loading":true}; poll again.

Properties — /data ​

/data reads and writes any property of any object — the same values the Inspector shows.

Write — POST /data ​

json
{
  "type": "exaObj",
  "uid": 123,
  "values": { "image": { "alpha": 50 } }
}
FieldDescription
typeRequired, always "exaObj". Without it the POST answers 200 and writes nothing
uidThe object's UID (number or string)
pathInstead of uid: "global" for engine-wide runtime state (show mode, audio-volume, …), or a dotted object path from the engine root, such as "project.comp1.media3" — a show object's path always starts with project.. Omit both uid and path to address the engine root
valuesThe properties to change — nested objects or dotted keys ({"image.alpha": 50} is the same as the example above)

A clip's opacity is image.alpha (0–100); a composition's, track's or group's is alpha.

Responses: 200 (empty body) · 404 object not found · 400 invalid JSON · 401 with an error when a value was rejected (for example a type mismatch) · 503 {"error":"loading"} while a project loads.

Read — GET /data ​

GET /data?type=exaObj&uid=123&values
GET /data?type=exaObj&path=global&values

Returns {"values": {…}, "revs": {…}}. Add &layout for the Inspector layout, or &tree for the object's children. When uid or path is present, type=exaObj is assumed; a GET with neither answers null.


Projector control — /cmd ​

POST /cmd with req: "pjlink" sends one PJLink command to one projector configured in the project (Control → TCP Devices). The request names the projector; its stored IP address, port and password are used:

json
{
  "req": "pjlink",
  "device": "Projector Left",
  "command": "POWR",
  "parameter": "1"
}

device is the projector's id or its name (the name is matched case-insensitively). The request never carries a host or a password — a password in the body is not read. An older integration that still sends ip (and port, default 4352) instead of device reaches only the configured projector at that address, with its stored password.

Response: {"response": "OK" | "ERROR", "message": "…", "value": "…", "reached": true} (value when the projector returned one; reached once the projector answered at all — absent when it could not be reached). A projector-side error, and a projector that cannot be reached, still answer HTTP 200 with "response": "ERROR" — check response, not only the status. The connection attempt gives up after 1.5 s (a projector that is off at the mains does not hold the request for the operating system's ~20 s connect timeout).

StatusWhen
200Sent — read response
400No project loaded, no command, no device (or ip) named, or the projector has no IP address
404No configured projector with that id or name (or at that ip/port)
500The engine could not open a network socket
503A project is loading

POST /cmd with {"req": "pjlink-poll"} asks the engine to check every configured projector's power now instead of at its next 30-second poll. It answers at once (200 {"response":"OK"}); the results arrive in the /info feed under pjlink (devices.<device id>.state = on / off / warming / cooling / unreachable / error / unknown). A machine with the client role does not poll and answers 409.

POST /cmd with {"req": "pjlink-all", "parameter": "1"} (on) or "0" (off) switches every PJLink projector configured in the project. Response: 200 {"response":"OK","message":"OK,<ok>/<total>"}; 400 without a project; 500 on failure; 503 while loading.

POST /cmd with {"req": "tcp", "device": "<id or name>"} sends a TCP device's stored command (add "command": "…" to send a different string; \r, \n, \xHH escapes work). The device is looked up in the project's Devices list — the request never names a host. Response: 200 {"response":"OK","device":"<name>"}; 404 unknown device; 400 no command; 502 cannot connect; 503 while loading.

PC power — /cmd ​

POST /cmd with req: "pc-power" wakes, restarts or shuts down a PC configured in the project (Control → System → PCs). Like pjlink, the request names the PC; its stored address and MAC address are used — the body never carries a host or a MAC:

json
{ "req": "pc-power", "device": "Render B", "action": "wake" }

device is the PC's id or its name (case-insensitive). action is wake, restart or shutdown.

  • wake — the engine sends a Wake-on-LAN magic packet (UDP port 9) as a broadcast on every attached network. {"response":"OK","via":"wake-on-lan","networks":<n>} means it was sent, not that the PC started.
  • restart / shutdown — the engine asks the PC's Exaplay Service (POST http://<pc>:<engine port + 1>/wdog {"command":"system:reboot"|"system:shutdown"}), which closes Exaplay and then Windows. When the Service does not confirm, the engine falls back to that PC's own /cmd system-restart / system-shutdown. via is service or engine.
StatusWhen
200Done — {"response":"OK","device":"<name>","action":"…","via":"…"}
400Unknown action, no project, no device, or the PC has no valid address (restart/shutdown) or MAC address (wake)
404No configured PC with that id or name
500The Wake-on-LAN packet could not be sent on any network
502Neither the PC's Service nor its engine answered (message says so)
503A project is loading

The Service's two commands can also be sent to it directly: POST /wdog with {"command":"system:shutdown"} or {"command":"system:reboot"} answers {"response":"OK","power":"shutdown"|"restart"} and then closes Exaplay and Windows. A Service from before these commands answers {"response":"OK"} without power and does nothing. Its GET /net/adapters now reports each adapter's mac.

An unknown /cmd req currently answers 200 — do not rely on that.


DALI lighting — /cmd ​

POST /cmd with req: "dali" sends one command to a DALI gateway configured in the project (Control → TCP Devices → DALI lighting). The request names the gateway, by id or name; its stored address is used:

json
{
  "req": "dali",
  "gateway": "House",
  "target": "G2",
  "action": "level",
  "value": 40,
  "fade": 3
}

target, action, value and fade are those of the DALI> cue line. The command is queued and the call answers at once: 200 {"response":"OK","queued":"G2 level 40% fade 3 s"}. A refusal answers 400 {"response":"ERROR","message":"…"} with the reason (unknown gateway, a target or fade the gateway does not support, a malformed value); a machine with the client role answers 409 {"response":"SKIPPED"} — the master sends; 503 while a project loads.

The outcome arrives in the /info feed under dali: gateways.<gateway id> = {state, detail?, last?, result?, checked_at}, where state is online / unreachable / error / unknown, last describes the last command and result is done, sent (a Helvar router did not acknowledge) or failed: <reason>. sending: false means this machine does not send (a client).

Error Responses ​

HTTP StatusMeaning
200Success (usually an empty body)
400Bad request (missing or invalid parameters, invalid JSON)
401project.load: {"message":"Project file not found"} or {"message":"Cannot open project"}. /data: a value was rejected
403{"message":"Read-only client: project editing is controlled by the master"} — a structural verb sent to a multi-client client. Transport verbs are allowed there
404Composition, cue or media item not found
422project.load: missing or unloadable media — {"message", "missing_files", "unloadable_files", "modified_path", "original_path"}
500The action failed (e.g. no cue matched in cue.go)
501Unknown req (and currently media.set-time with an unknown UID)
503A project is loading; every verb except project.load / project.new is refused until it finishes — POST /project, /data, /cue/trigger, /composition/play, /stop, /control/fire and the /cmd projector and TCP-device verbs alike (POST /project, /cue/trigger, /composition/play and /stop answer with {"error":"loading","loading":true})

Cross-Reference: Other Protocols ​

Seeking a composition is also available over TCP and UDP. The dotted form is the primary syntax; the classic form still works:

ProtocolCommandDocumentation
TCPcomp1.set:time=SECONDS (classic: set:cuetime,comp1,SECONDS)TCP API
UDPcomp1.set:time=SECONDS (classic: set:cuetime,comp1,SECONDS)UDP API

For OSC control see the OSC API.


Examples ​

Python ​

python
import requests

HOST = "http://192.168.1.5:8123"

# Seek a Playlist to 30 seconds (within the current item)
requests.post(f"{HOST}/project", json={
    "req": "cuelist.seek",
    "src-uid": 12345678,
    "time": 30.0,
})

# Play a composition
requests.post(f"{HOST}/project", json={
    "req": "composition.play",
    "src-uid": 12345678,
})

# Stop a composition
requests.post(f"{HOST}/project", json={
    "req": "composition.stop",
    "src-uid": 12345678,
})

# Set a clip's opacity to 50 %
requests.post(f"{HOST}/data", json={
    "type": "exaObj",
    "uid": 87654321,
    "values": {"image": {"alpha": 50}},
})

JavaScript / Node.js ​

javascript
const HOST = "http://192.168.1.5:8123";

// Seek a Playlist to 15 seconds
await fetch(`${HOST}/project`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    req: "cuelist.seek",
    "src-uid": 12345678,
    time: 15.0,
  }),
});

curl ​

bash
# Seek a Playlist to 45.5 seconds
curl -X POST http://192.168.1.5:8123/project \
  -H "Content-Type: application/json" \
  -d '{"req":"cuelist.seek","src-uid":12345678,"time":45.5}'

# Play a composition
curl -X POST http://192.168.1.5:8123/project \
  -H "Content-Type: application/json" \
  -d '{"req":"composition.play","src-uid":12345678}'

Exaplay 3 User Documentation