REST API
Everything lives on port 6006. Reads (GET) are open on your local network; writes (POST/PUT/DELETE) need the Bearer token — see Setup & Authentication.
The live app is the reference. GET /manifest returns every operation your installed version supports, complete with JSON input schemas and a risk rating. GET /info is the lighter version — device identity plus a flat endpoint list. Both are unauthenticated, so you can check them from a browser. The tables below are a map, not a spec.
curl http://192.168.1.42:6006/manifest
State and health
| Endpoint | What it returns |
|---|---|
GET /info |
Device name, model, port, and the complete endpoint list |
GET /health |
Liveness check |
GET /manifest |
Every operation with its JSON input schema, domain, access level, and risk |
GET /state |
Full production state — switcher, inputs, mixer, output, scenes, recording |
Switcher
| Endpoint | Action |
|---|---|
GET /switcher |
Program and preview ids, transition state, FTB state |
POST /switcher/preview |
{"source_id": "…"} — or null to clear |
POST /switcher/program |
{"source_id": "…"} — cuts straight to air |
POST /switcher/cut |
Preview becomes program, instantly |
POST /switcher/take |
Animated transition, preview → program |
POST /switcher/fade |
Fade preview → program |
POST /switcher/transition |
Set transition type and duration |
POST /switcher/cancel |
Abort a running transition |
POST /switcher/ftb |
{"active": true} or {"active": false} — fade to black |
/switcher/transition awaits the transition, so a 1.5-second fade takes 1.5 seconds to respond. Use /switcher/cut when you want instant.
Inputs
| Endpoint | Action |
|---|---|
GET /inputs |
Slots and their source_ids. Add ?metrics=1 for per-source metrics |
POST /inputs/quick-add |
Add a source: color_bars, solid_color, ndi, quiccam, media, audio_interface |
POST /inputs/discovery |
{"action": "start"} / {"action": "stop"} — hunt for NDI sources and wireless iPhone cameras |
GET /inputs/discovered |
What discovery found |
PUT /inputs/{slotIndex} |
Configure a slot |
POST /inputs/{slotIndex}/media |
Load a video or image file |
DELETE /inputs/{slotIndex} |
Clear one slot |
DELETE /inputs |
Clear all slots |
Media assets
Getting a video or image onto the iPad, without the Files app or AirDrop.
| Endpoint | Action |
|---|---|
POST /media |
Upload a file — the raw bytes are the body |
POST /media/fetch |
Have the iPad download from a URL you give it |
GET /media |
List the assets in the current project |
DELETE /media/{asset_id} |
Delete one (file and record) |
Deleting an asset that an input slot still has loaded is refused with asset_in_use, and the message names the slot — clear it first, or add ?force=true if you meant it.
Upload puts the raw file in the body — no multipart, no base64. Pass the filename as ?name= or an X-Filename header; the extension decides whether it’s treated as video or image:
curl -X POST "http://192.168.1.42:6006/media?name=stinger.mp4" \
-H "Authorization: Bearer <token>" \
--data-binary @stinger.mp4
You get back an asset_path, which is what you hand to inputs_add_media:
{ "ok": true, "asset_id": "…", "asset_path": "Assets/Media/<uuid>.mp4",
"original_name": "stinger.mp4", "media_type": "video", "bytes": 18234112 }
If the file is somewhere reachable by URL instead, POST /media/fetch with {"url": "…"} has the iPad pull it down itself — including from a web server on your own laptop.
Uploads land in the current project, exactly where a file picked in the app would, so they’re saved and loaded with it. Accepted formats are the ones the app’s own pickers take: mov, mp4, m4v, qt for video; png, jpg, heic, gif, bmp, tiff, webp for images. The cap is 512 MB per file, one upload at a time.
Use asset_path, not path. inputs_add_media accepts either, but only asset_path survives a project save and reload — path points at a loose file in Documents that the project doesn’t track.
Mixer
| Endpoint | Action |
|---|---|
GET /mixer |
Rows, volumes, mutes, groups |
PUT /mixer/master |
{"volume": 0.0–1.0} |
PUT /mixer/sources/{id} |
volume, mute, afv, group_id, av_sync_trim_ms |
POST /mixer/groups |
Create a custom group |
PUT /mixer/groups/{id} |
Rename, set volume, mute, collapse |
DELETE /mixer/groups/{id} |
|
POST /mixer/pfl |
Pre-fade listen |
POST /mixer/monitor |
Monitor program or preview |
Volumes are always 0.0 (silent) to 1.0 (unity), never dB. Split-channel rows use <source_id>_L and <source_id>_R. Pass group_id: null to return a source to Ungrouped.
Output and streaming
| Endpoint | Action |
|---|---|
GET /output |
Output state, destinations[], active destination, reconnect and throughput telemetry |
POST /output/start |
Start all enabled outputs, or {"kinds": [...]} for a subset |
POST /output/stop |
Stop all, or a subset |
PUT /output/settings |
Resolution, bitrate, codec, and so on |
Destinations
The Your Destinations multistream list is fully manageable:
| Endpoint | Action |
|---|---|
GET /output/destinations |
The list |
POST /output/destinations |
Add one |
PUT /output/destinations/{id} |
Update one |
DELETE /output/destinations/{id} |
Remove one |
Picking a platform by service_id ("twitch", "youtube", "kick", …) without a URL fills in that platform’s ingest URL, just like the tiles in the app.
Stream keys are write-only — you can set them, but they are never echoed back. Each destination reports a key_source: manual (you pasted a key, stored in the iPad’s Keychain) or oauth (a Twitch or YouTube sign-in resolves the key at go-live and it is never persisted). Deleting a destination deletes its stored key.
GET /output also enriches the RTMP entry with reconnect and throughput telemetry — reconnect_attempt, connection_status, last_error, disconnect_count, bytes_sent, frames_sent, dropped_frames, send_bitrate_bps, since_last_send_s. That last one is the sharpest health signal: when the wire dies, the OS buffers locally, so queue depth stays flat while since_last_send_s climbs.
Scenes and recording
| Endpoint | Action |
|---|---|
GET /scenes / GET /scenes/{id} / GET /scenes/active |
Saved production-state snapshots |
POST /scenes |
Save the current state as a scene |
POST /scenes/{id}/activate |
Recall a scene |
POST /scenes/{id}/duplicate · POST /scenes/{id}/rename · DELETE /scenes/{id} |
|
GET /recording · POST /recording/start · POST /recording/stop |
Cameras
| Prefix | What it covers |
|---|---|
/ptz/* |
Pan, tilt, zoom, presets, focus, centre, stop |
/cinematic/* |
Slow programmed moves — hold, standby, zoom-to, pan-slow, idle-scan |
/camera/* |
Wireless iPhone cameras — zoom, focus, exposure, white balance, torch, front/back, subject tracking, gimbal |
/gimbal/poses/* |
Save and recall named DockKit gimbal positions |
/ptz/* drives the preview camera. If you pass a source_id that isn’t on preview you get 409 not_on_preview rather than a silent no-op.
Diagnostics and A/V sync
| Endpoint | What it returns |
|---|---|
GET /diagnostics/health |
Overall app health |
GET /diagnostics/levels |
Audio levels |
GET /diagnostics/network |
Network conditions |
GET /diagnostics/latency |
End-to-end latency breakdown |
GET /diagnostics/audio-capture |
The iPad’s own audio: session state, mic permission, which sources hold the capture path, and the current output route |
GET /diagnostics/audio-decoders |
Per-source audio decode counters for SRT, RTP and pMix cameras |
GET /diagnostics/quiccam-sender |
Per-camera health of the pMix camera sender’s listener watchdog |
GET /avsync |
A/V sync and multi-camera sync status |
PUT /avsync/settings |
Write any subset; applied immediately |
POST /avsync/calibrate |
One-shot chirp calibration |
The three reads above answer why, not just what:
- No sound from the iPad’s mic or an audio interface →
/diagnostics/audio-capture. It reports the audio session, the mic permission, which sources currently hold the capture path, and the output route — including a short history of route changes, so a monitor that died when a projector was plugged in is still visible after the adapter comes out. - A network source has gone silent →
/diagnostics/audio-decoders. It separates a source receiving no audio at all from one receiving audio the decoder can’t parse from one decoding fine into silence. - One pMix camera keeps dropping out while the others are fine →
/diagnostics/quiccam-sender. Watchdog state, restart counts, and a ring of recent lifecycle transitions per camera.
Live events (SSE)
GET /events?topics=*
A server-sent-events stream of production state. Subscribe to switcher, inputs, mixer, output, scenes, recording, levels, or * for everything. You get a full snapshot on connect, deltas as things change (20 Hz), and per-source audio levels every tick — enough to build an external meter bridge or a tally display.
Use this instead of polling /state in a loop.
Audio levels arrive as a 4-tuple per source: [rms_left, rms_right, peak_left, peak_right], all amplitudes 0–1 rather than dBFS.
Frame grabs
| Endpoint | What you get |
|---|---|
GET /snapshot |
Current PROGRAM frame as JPEG |
GET /snapshot/{source_id} |
A specific source’s latest frame |
GET /screenshot |
The full app window — requires the token |
All three accept ?max_w=<px> (default 1280) and ?q=<0..1> (default 0.7).
/snapshot renders the pixel buffer, so it shows what a source decodes. /screenshot captures the live window, so it shows what the operator actually sees. Comparing the two on the same source separates a decode fault from a render fault.
How old is that frame?
/snapshot returns the last frame a source decoded, which is not necessarily a current one. A source that stopped producing keeps serving its held frame with a 200, which is exactly how a dead input gets declared healthy.
Every 200 carries the verdict in its headers:
| Header | Meaning |
|---|---|
X-Frame-Freshness |
live, stale, held, or unknown |
X-Frame-Age-S |
Seconds since that source last produced a frame |
X-Source-State |
The same value GET /inputs reports as connection.state |
stale means you’re looking at a held last frame. held is not a fault — a still image, a text source or a paused clip holds one frame by design, so age says nothing about its health there.
The image is never withheld because of its age; you get the picture and the verdict, and decide.
POST /vision/describe and POST /vision/detect resolve their frame the same way, so they return frame_age_s, frame_freshness and source_state as fields in the response envelope — an assistant describing a frame can tell you it’s describing a picture from four minutes ago.
Wire-format conventions
- Volumes — 0.0 (silent) to 1.0 (unity). Never dB.
- Bitrates — bits per second; the field name ends
_bps. - Durations — seconds; the field name ends
_s. - Frame rates — a plain
fpsnumber. Pixel sizes are barewidth/height. - Keys — snake_case. Enum values are lowercase camelCase (
crossDissolve,easeInOut). - IDs — all strings. Get
source_ids fromGET /inputs;slot_indexis the integer 0–63 position,slot_idthe durable identifier.
Errors
Every 4xx/5xx response uses the same envelope:
{"error": {"code": "destination_not_found", "message": "…"}}
When you send an unknown value for an enum field, the message lists the valid ones — so both humans and AI clients can correct themselves without a docs lookup.
A request with several problems reports all of them in one 400, rather than making you fix one field per round trip.
Codes you’re most likely to meet:
| Status | Codes |
|---|---|
| 400 | bad_request, missing_<field>, invalid_<field>, request_too_large |
| 401 | unauthorized — this call needs the Bearer token |
| 404 | not_found, slot_not_found, source_not_found, scene_not_found, destination_not_found, asset_not_found |
| 405 | method_not_allowed |
| 408 | upload_timeout — an upload stalled or ran past the time limit |
| 409 | not_on_preview, already_assigned, aspect_ratio_locked, upload_in_progress, asset_in_use |
| 413 | request_too_large — the file is over the 512 MB upload cap |
| 422 | no_empty_slot, not_a_media_source, not_a_camera_source, fetch_failed |
| 503 | switcher_unavailable, mixer_unavailable, output_unavailable — the app is still starting up |
| 507 | insufficient_storage — not enough free space on the iPad |