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 fps number. Pixel sizes are bare width / height.
  • Keys — snake_case. Enum values are lowercase camelCase (crossDissolve, easeInOut).
  • IDs — all strings. Get source_ids from GET /inputs; slot_index is the integer 0–63 position, slot_id the 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

Back to top

© 2026 pMix Studio. Built by Smash It Studios.

© 2026 pMix Studio · pmix.studio ·