REST API reference
The REST API is the engine itself; the MCP server and the SDK are both clients of it. Eight endpoints, JSON in and JSON out, one bearer token. Every response states what it covered, so nothing is silently left out.
Last updated
Endpoints
Base URL https://api.tryplayhead.com. JSON in, JSON out, one bearer token.
| Method | Path | What it does |
|---|---|---|
POST | /v1/frames | The endpoint everything else is built on. Returns sheets, exact frame times, what was covered, the estimated vision-token cost, and the cuts, freezes, black frames and silences that were measured. |
POST | /v1/transcript | Text only — no images, so no vision tokens at all. The cheapest call in the API by a wide margin. |
POST | /v1/uploads | For footage that is private, geo-blocked, or never was on a platform. Returns an upl_… id that is a valid url everywhere else. |
POST | /v1/uploads/tickets | A short-lived signed URL somebody else can POST bytes to without any credential of yours. |
GET | /v1/jobs/{jobId} | Poll this after a call made with sync false. The result field appears on success and holds exactly what the synchronous call would have returned. |
GET | /v1/credits | Two balances that behave nothing alike, the plan's limits, and what has been spent. |
GET | /v1/detail-levels | The six levels with their frame rates, grids, window caps and prices — straight from the engine. Needs no authentication. |
DELETE | /v1/videos/{videoId} | Stored sheets, the transcript, the cached source file and every derived artifact. |
A machine-readable version of everything on this page is at /openapi.json, and the same calls are wrapped with types by @playhead/sdk.
Turn a video into contact sheets
Omit start and end and the response covers the complete video — there is no default that quietly clips the runtime. With sync true, which is the default, the work happens inside the request and a long video can take minutes; pass sync false to get a job_id back immediately and poll it.
POST /v1/frames
- urlstringrequired
- A link to the video, or an upl_… id from an upload. Required unless upload_id is given.
- upload_idstring
- The id returned by POST /v1/uploads. Use instead of url.
- detailstringdefault auto
- overview, standard, detail, fine, motion or single. auto picks from the window length.
- startnumber | string
- Window start, in seconds (12.5) or as a clock (0:12.5). Omit for the whole video.
- endnumber | string
- Window end.
- transcribebooleandefault true
- Transcribe the audio alongside the frames.
- languagestringdefault auto
- Force a language, e.g. en. Worth setting when the audio is mostly music or noise — detection misfires there, and naming it skips a model load.
- qabooleandefault true
- Run the cut, freeze, black-frame and silence detectors. Skipped automatically past a 15-minute window.
- signalsoff | basic | fulldefault full
- How much of the between-frame pass to run — what happened in the frames the sheet does not show.
- overlapbooleandefault false
- Overlap consecutive sheets by one frame.
- returnurls | base64default urls
- Signed time-limited links, or the JPEG bytes inline. base64 is what an MCP client needs.
- syncbooleandefault true
- false queues the work and returns a job_id. The right choice for anything long.
- idempotency_keystring
- Replaying the same key returns the first result rather than charging twice.
- webhook_urlstring
- Called once when a queued job reaches a terminal state.
- fps / cols / rows / sheet_widthnumber
- Raw overrides that replace the preset. For callers who know why.
curl -X POST https://api.tryplayhead.com/v1/frames \
-H "Authorization: Bearer $PLAYHEAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"detail": "standard"
}'{
"job_id": "job_01HZY6M8Q2",
"status": "succeeded",
"credits_charged": 4,
"cache_hit": false,
"video": {
"id": "vid_01HZY6M8Q2",
"platform": "youtube",
"duration": 213.4,
"width": 1920,
"height": 1080,
"src_fps": 25
},
"detail": "standard",
"fps": 5,
"window": [0, 213.4],
"coverage": {
"covered_seconds": 213.4,
"of_total": 213.4,
"is_full_video": true,
"gaps": []
},
"geometry": {
"grid": [6, 5],
"frames_per_sheet": 30,
"frame_interval_ms": 200
},
"total_frames": 1067,
"total_estimated_vision_tokens": 53350,
"sheets": [
{
"page": 1,
"t0": 0,
"t1": 5.8,
"n_frames": 30,
"frame_times": [0, 0.2, 0.4, "…"],
"url": "https://api.tryplayhead.com/v1/assets/…?exp=…&sig=…",
"spoken": "We're no strangers to love…"
}
],
"technical": {
"scene_cuts": [3.4, 7.8, 12.1],
"pacing": { "cuts": 42, "cuts_per_minute": 11.8 },
"freezes": [],
"blacks": [{ "start": 0, "end": 0.4 }],
"silence_gaps": [{ "start": 96.2, "end": 97.4 }],
"skipped": false
},
"transcript": { "language": "en", "source": "captions", "text": "…" }
}Fields worth reading
- coverage.is_full_video
- True when this is second 0 to the end, nothing left out. The field that makes an answer trustworthy.
- coverage.gaps
- Every second not covered. Empty is a claim, not an absence of information.
- sheets[].frame_times
- The exact second of every tile, left to right then top to bottom — the same numbers burned into the picture.
- technical.skipped
- True when the detectors never ran. Without it, a video with no cuts and a video nobody looked at produce identical output.
- total_estimated_vision_tokens
- What this response will cost the model that reads it, before you send it.
Get the words, with the times they were said
The uploader's own captions are preferred when they exist, because a human typed the names; otherwise the audio is transcribed, and the response says which of the two happened. Every response also reports whether the audio was speech at all — music scored over a video transcribes into fluent sentences nobody said, and that comes back as an assessment rather than as dialogue.
POST /v1/transcript
- urlstringrequired
- A link to the video, or an upl_… id from an upload. Required unless upload_id is given.
- upload_idstring
- The id returned by POST /v1/uploads. Use instead of url.
- wordsbooleandefault false
- Include per-word timings.
- languagestringdefault auto
- Force a language, e.g. de.
- startnumber | string
- Window start.
- endnumber | string
- Window end.
- formatjson | srt | vtt | textdefault json
- srt and vtt return a subtitle file in the subtitles field.
- syncbooleandefault true
- false queues the work.
curl -X POST https://api.tryplayhead.com/v1/transcript \
-H "Authorization: Bearer $PLAYHEAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://vimeo.com/76979871", "words": true}'{
"job_id": "job_01HZY7A3B1",
"status": "succeeded",
"language": "en",
"source": "transcribed",
"text": "So the first thing you'll want to do is…",
"segments": [
{
"start": 0.48,
"end": 3.12,
"text": "So the first thing you'll want to do is",
"words": [{ "start": 0.48, "end": 0.61, "word": "So" }]
}
],
"speech": { "is_speech": true, "confidence": 0.97 }
}Fields worth reading
- source
- captions when the uploader wrote them, transcribed when we heard them. They differ in reliability and that is your business, not ours to hide.
- speech.is_speech
- False when the audio is music or noise. A transcript of a song is fluent and entirely invented; this is the field that says so.
- low_confidence
- Passages the model was unsure of. Settle these against the frames.
Send your own file
Editing and delivery containers work, not just web video: mp4, mov including ProRes, mkv, webm, avi, mxf covering XAVC, XDCAM, AVC-Intra and DNxHD, plus gxf, lxf, ts and dv. Camera raw — .r3d, .braw, .ari, .crm — needs the manufacturer's own decoder and is refused by name, with the export to make instead. Uploading the same file twice lands on the same video id, which is what makes the second call cheap rather than a re-run.
POST /v1/uploads
- filemultipart/form-datarequired
- The bytes. Streamed to disk, never buffered, so file size does not become memory.
curl -X POST https://api.tryplayhead.com/v1/uploads \
-H "Authorization: Bearer $PLAYHEAD_API_KEY" \
-F "file=@./exports/cut-v4.mov"{
"upload_id": "upl_01HZY6M8Q2",
"video": {
"id": "vid_8f2c…",
"platform": "upload",
"duration": 47.9,
"width": 3840,
"height": 2160,
"src_fps": 25,
"start_timecode": "14:36:27:07",
"audio_tracks": 8
},
"size_bytes": 184320000,
"received_bytes": 2147483648
}Fields worth reading
- video.start_timecode
- The source's own SMPTE clock, when it has one. A different clock from the elapsed seconds everywhere else — both are true, and neither should be converted into the other.
- size_bytes vs received_bytes
- What we hold after any proxy transcode, versus what you sent. A 2 GB master is stored as a much smaller proxy; the identity of the video stays the master's.
Get a signed upload URL that needs no key
This is how an agent running on a machine the API cannot reach sends a local file up. Your server asks for a ticket; the ticket travels to wherever the bytes are; the signature is the authorization, so no key ever leaves your infrastructure.
POST /v1/uploads/tickets
curl -X POST https://api.tryplayhead.com/v1/uploads/tickets \
-H "Authorization: Bearer $PLAYHEAD_API_KEY"{
"ticket": "eyJvcmciOiJvcmdf…",
"upload_url": "https://api.tryplayhead.com/v1/uploads/t/eyJvcmciOiJvcmdf…",
"expires_at": "2026-08-06T18:20:00Z",
"max_bytes": 2147483648
}Status and result of a queued job
status moves queued → running → succeeded or failed. A failure carries the same actionable error body a synchronous call would have thrown, so the suggestion field survives the queue intact.
GET /v1/jobs/{jobId}
curl https://api.tryplayhead.com/v1/jobs/job_01HZY6M8Q2 \
-H "Authorization: Bearer $PLAYHEAD_API_KEY"{
"job_id": "job_01HZY6M8Q2",
"status": "succeeded",
"operation": "frames",
"detail": "overview",
"credits_charged": 12,
"cache_hit": false,
"queued_at": "2026-08-06T17:02:11Z",
"finished_at": "2026-08-06T17:04:48Z",
"error": null,
"result": { "sheets": ["…"], "coverage": { "is_full_video": true } }
}Balance, plan and ledger
credits_balance is the monthly grant and resets on credits_renew_at. topup_balance is bought credit and never resets. wallet says which of the two this particular credential spends — an API key billed from bought credit cannot spend the plan's grant at all.
GET /v1/credits
- limitintegerdefault 20
- How many ledger entries to return. Maximum 200.
curl https://api.tryplayhead.com/v1/credits \
-H "Authorization: Bearer $PLAYHEAD_API_KEY"{
"org_id": "org_01HZ…",
"tier": "pro",
"credits_balance": 1840,
"credits_renew_at": "2026-09-01T00:00:00Z",
"topup_balance": 0,
"wallet": "plan",
"plan": {
"monthly_credits": 2000,
"concurrency": 4,
"retention_days": 30,
"max_video_minutes": 180,
"allows_motion": true
},
"ledger": [
{ "delta": -4, "reason": "frames", "job_id": "job_01HZY6M8Q2", "balance_after": 1840 }
]
}The levels and what they cost
Read this rather than hard-coding the table: it comes out of the same objects the engine enforces, so a client built against it cannot drift from what the server will actually accept.
GET /v1/detail-levels
curl https://api.tryplayhead.com/v1/detail-levels{
"levels": [
{
"detail": "motion",
"fps": 30,
"grid_16_9": [2, 1],
"frames_per_sheet": 2,
"max_window_seconds": 3,
"on_exceed": "error",
"credits": { "unit": "sec", "rate": 3 },
"max_sheets_per_call": 2,
"purpose": "animations, motion paths, easing curves"
}
],
"plans": { "free": { "price_usd": 0, "monthly_credits": 60, "allows_motion": false } }
}Erase a video and everything derived from it
This is the GDPR erasure path, so it does not tombstone. After it returns the data is gone, and a later call about the same URL is a fresh, chargeable run rather than a cache hit.
DELETE /v1/videos/{videoId}
curl -X DELETE https://api.tryplayhead.com/v1/videos/vid_01HZY6M8Q2 \
-H "Authorization: Bearer $PLAYHEAD_API_KEY"{ "video_id": "vid_01HZY6M8Q2", "deleted": true }Questions
- What is the base URL?
- https://api.tryplayhead.com. Every endpoint is under /v1 except /healthz and /openapi.json.
- How do I analyse a whole video rather than part of one?
- Omit start and end. There is no default that clips the runtime, and coverage.is_full_video on the response confirms it — the field exists so nothing has to be assumed.
- How long can a synchronous request take?
- A cold survey of an hour of video is minutes of real work. Pass sync false for anything long: the call returns a job_id immediately and GET /v1/jobs/{id} carries the result when it settles.
- Are repeated calls about the same video charged twice?
- A window already paid for costs 20% of the original, and the response says cache_hit true. An idempotency_key goes further — replaying one returns the first result rather than doing the work again.