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, risk, and the REST route that answers it |
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, uvc |
POST /inputs/discovery |
{"action": "start"} / {"action": "stop"} — hunt for NDI sources, wireless iPhone cameras and attached capture cards. Optional backends: any of ndi, quiccam, uvc |
GET /inputs/discovered |
What discovery found. Capture cards come with the resolutions and frame rates they advertise |
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 |
Reading a slot’s connection. input_type is the category of thing in the slot, so every pMix phone camera reports "WebRTC" whatever it connected over. The transport it is actually running on is connection.transport (quicLAN, webrtc, srt or rtp) and what it asked for is connection.transport_intent (those four, or auto). Judge a camera by comparing the two: webrtc against an intent of auto means the slot wanted QUIC and did not get it. Both keys are omitted, never guessed, on sources that are not camera transports.
Reading a slot’s frame rate. decoded_video.fps is counted after the mixer’s own thumbnail thinning, so a camera that is only a thumbnail reads low on purpose. fps_provenance says which it is — mixerDecimated (by design) or fullRate — with display_priority naming the cause, wire_fps carrying the arrival rate where the transport can count it, and frame_skip_rate present only when frames really were skipped. A stalled NDI camera reports no rate at all rather than its last healthy one.
Project
| Endpoint | Action |
|---|---|
GET /project |
The current project, its frame rate, the Camera Latency preset, and session_is_saved_project |
PUT /project/camera-latency |
{"preset": "low"\|"standard"\|"reliable"} — applied live and saved with the project |
session_is_saved_project tells you whether a write will reach a saved show or only the automatic last-session snapshot. Writes on a session that has never been saved apply immediately and no longer leave a stray Untitled project behind.
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 |
/avsync/optical-calibration |
The screen-flash camera latency pass. TestFlight builds only; App Store builds answer 403 |
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 |