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.

Every endpoint in the Playhead REST API
MethodPathWhat it does
POST/v1/framesThe 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/transcriptText only — no images, so no vision tokens at all. The cheapest call in the API by a wide margin.
POST/v1/uploadsFor 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/ticketsA 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/creditsTwo balances that behave nothing alike, the plan's limits, and what has been spent.
GET/v1/detail-levelsThe 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.
Request — turn a video into contact sheetsbash
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"
  }'
Responsejson
{
  "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.
Request — get the words, with the times they were saidbash
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}'
Responsejson
{
  "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.
Request — send your own filebash
curl -X POST https://api.tryplayhead.com/v1/uploads \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY" \
  -F "file=@./exports/cut-v4.mov"
Responsejson
{
  "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

Request — get a signed upload url that needs no keybash
curl -X POST https://api.tryplayhead.com/v1/uploads/tickets \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY"
Responsejson
{
  "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}

Request — status and result of a queued jobbash
curl https://api.tryplayhead.com/v1/jobs/job_01HZY6M8Q2 \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY"
Responsejson
{
  "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.
Request — balance, plan and ledgerbash
curl https://api.tryplayhead.com/v1/credits \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY"
Responsejson
{
  "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

Request — the levels and what they costbash
curl https://api.tryplayhead.com/v1/detail-levels
Responsejson
{
  "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}

Request — erase a video and everything derived from itbash
curl -X DELETE https://api.tryplayhead.com/v1/videos/vid_01HZY6M8Q2 \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY"
Responsejson
{ "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.