REST API reference

The REST API is the whole product, and the MCP server and the SDK are both clients of it. JSON in and JSON out, one bearer token. Every answer states what it covered, so nothing is left out without you being told.

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/watch_videoThe endpoint everything else is built on. Ask a question about a video and get the answer in words, with the second of every moment it names.
POST/v1/batchesThe same question about twenty videos, in one request, with one place to collect the answers. Use this instead of a loop.
GET/v1/batches/{batch_id}The batch and every answer in it. Add ?wait=60 and the read holds open until the batch finishes.
POST/v1/transcriptText only. No pictures are read, which makes this the cheapest call in the API by a wide margin.
POST/v1/uploadsFor footage that is private, unreleased, or never was on a platform. Returns an upl_… id you can use anywhere a url goes.
POST/v1/uploads/ticketsA short-lived signed URL somebody else can POST bytes to without any credential of yours.
POST/v1/uploads/directA link that writes straight into object storage. Two short calls and one transfer that never touches our servers.
GET/v1/jobs/{jobId}Read this after a call made with sync false. The result field appears on success and holds exactly what the waiting call would have returned.
GET/v1/creditsTwo balances that behave nothing alike, the plan's limits, and what has been spent.
DELETE/v1/videos/{videoId}The video, its transcript, everything that was read from it, and the stored copy of the file.

The same calls come wrapped with types in @playhead/sdk, and your agent can call them itself through the MCP server.

Ask a video a question

Leave question out and the answer covers the complete video, second zero to the end. Every answer carries a session_id. Pass it with the next question about the same video and that question reads what the first look already found, instead of watching the video again. That is why the second question is the quick one.

POST /v1/watch_video

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.
questionstring
What you want to know. Omit it for what happens across the complete video.
session_idstring
The conversation about one video, from an earlier answer. It is what makes a follow-up cheap. With it, url and upload_id can be left out.
contextstring[]
What you already know and the video cannot show: what the person is building, and what they asked. Plain sentences. It costs almost nothing and the answer is better for it.
windowobject
The part of the recording to read, as { from, to } in seconds from the start of the file. Omit it for the whole video. Only these seconds are fetched, decoded, transcribed and read, so three minutes of a two hour podcast costs three minutes. The plan's length limit measures this part, not the file.
regionnumber[]
The part of the picture to read, as [x0, y0, x1, y1] in fractions of the frame. Use it with window for a follow-up about one corner of one moment: window says when, region says where. It makes small text large enough to read. Fractions rather than pixels, so the same question works on a landscape recording and a vertical one.
depthstringdefault full
How much of the video to read: glance, read or full. It sets how many moments are looked at, which changes how much the answer says and never how exact it is. Every second in an answer is a second that was read, at all three depths. Use glance to decide whether a recording is worth reading properly: it costs about a sixth of a full reading, and the answer names the stretches it did not cover.
schemaobject
A JSON Schema. The answer then comes back in value, in that shape, so your code reads values instead of parsing sentences. A field the video cannot fill comes back null, with its reason in unanswered.
instructionsstring
How the answer should read, in your words instead of ours. It changes the wording and nothing else.
rewatchboolean
Watch the whole video again and pay for it again. Ask without it first: the answer states the price and starts nothing.
idempotency_keystring
Replaying the same key returns the first answer rather than charging twice.
Request: ask a video a questionbash
curl -X POST https://api.tryplayhead.com/v1/watch_video \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "question": "What does the on-screen text say, and when?"
  }'
Responsejson
{
  "session_id": "ses_01HZY6M8Q2",
  "answer": "A woman in a red coat speaks to camera from 0.0s. At 3.2s the screen reads BLACK FRIDAY, in white on black. The shot cuts at 5.1s to a close-up of the product, and the price 39.99 appears at 12.0s.",
  "unanswered": [],
  "unresolved": [
    {
      "from": 3,
      "to": 3.9,
      "reason": "The text in the dialog is too small to read at this size.",
      "region": [0.5, 0, 1, 0.5]
    }
  ],
  "cached": false,
  "seen_at": [0, 3.2, 5.1, 12],
  "credits_charged": 150,
  "ms": 42300
}

Fields worth reading

session_id
Pass this with the next question about the same video. It is the difference between a few hundred tokens and a few thousand.
answer
The reading, in words, with the second of every moment it names. The times come from the video, so they cannot drift from the moment they belong to.
unanswered
What the question asked for and the video does not show, named rather than guessed at. Each entry says which field and why.
unresolved
The stretches the reading could not settle, and exactly what to send to settle them: from and to go back as window, and region goes back as region. region is null where there was nothing to point at, and a second look at the whole frame is better than one at the wrong corner. Asking again costs the seconds you name, not the video.
cached
True when this exact question had been answered before. It then costs you nothing.
seen_at
The seconds this answer was read from.

Ask about several videos at once

The fields at the top go to every item, and an item may set its own instead. So twenty videos and one question is one question and twenty sources. Every video is read on its own and no reading sees another, which is what makes the answers comparable. It costs one reading per video, the same as asking one by one. What it saves is the round trips and the waiting.

POST /v1/batches

itemsobject[]
The videos. Each item takes url or upload_id, and may carry its own question, schema and instructions. Give each one a ref and it comes back beside its answer, so you never match answers by position.
questionstring
Asked of every item that does not carry its own.
schemaobject
A JSON Schema for every item. Each answer then comes back in value, in that shape.
instructionsstring
How every answer should read. An item may override it.
verbstring
watch_video, which is also the default and today the only one.
Request: ask about several videos at oncebash
curl -X POST https://api.tryplayhead.com/v1/batches \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "What is the hook, and when does the call to action appear?",
    "items": [
      { "url": "https://example.com/ad-a.mp4", "ref": "a" },
      { "url": "https://example.com/ad-b.mp4", "ref": "b" },
      { "url": "https://example.com/ad-c.mp4", "ref": "c" }
    ]
  }'
Responsejson
{
  "batch_id": "bat_01HZY6M8Q2",
  "verb": "watch_video",
  "status": "queued",
  "total": 3
}

Fields worth reading

batch_id
Read the answers with GET /v1/batches/{batch_id}. Add ?wait=60 and the read holds until the batch finishes, so short videos are usually one call and not a poll.
status
queued, running, succeeded, partial, failed or cancelled. partial means some items answered and some did not: the answers that arrived are yours and were paid for.

Collect the answers

wait takes 0 to 60 seconds. It returns as soon as the batch closes, or when the seconds run out with whatever is finished. An item that failed keeps its place and says why, so nineteen answers never read as twenty. Every item carries the session_id of its own video, so a follow-up about one of them is a cheap question rather than a second reading.

GET /v1/batches/{batch_id}

waitnumber
Seconds to hold the connection, 0 to 60. 0 answers at once with whatever is finished.
Request: collect the answersbash
curl "https://api.tryplayhead.com/v1/batches/bat_01HZY6M8Q2?wait=60" \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY"
Responsejson
{
  "batch_id": "bat_01HZY6M8Q2",
  "status": "succeeded",
  "total": 3,
  "done": 3,
  "failed": 0,
  "credits_charged": 450,
  "items": [
    {
      "ref": "a",
      "run_id": "trun_01HZY6M8Q3",
      "status": "succeeded",
      "session_id": "ses_01HZY6M8Q4",
      "answer": "The hook is a first-person claim at 0.0s. The call to action reads Shop the sale from 11.2s.",
      "value": null,
      "unanswered": [],
      "credits_charged": 150,
      "error": null
    }
  ]
}

Fields worth reading

done
How many items answered. failed says how many did not.
items[].ref
The name you gave that item. Match answers by this, never by position.
items[].value
The shaped answer, when the batch or the item sent a schema.
items[].error
Why this one failed, when it did. The others are unaffected and were paid for.

Get the words, with the times they were said

The uploader's own captions are used when they exist, because a person typed the names. Otherwise the audio is transcribed, and the answer says which of the two happened. Every answer also says whether the audio was speech at all. Music over a video comes back as fluent sentences nobody said, so this field tells you before you act on them.

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. The two differ in how reliable they are, so you are told which you got.
speech.is_speech
False when the audio is music or noise. A transcript of a song reads fluently and is entirely invented, and 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 formats 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 files such as .r3d, .braw, .ari and .crm need the camera maker's own software, so those are refused by name with the export to make instead. Sending the same file twice lands on the same video, which is what makes the second call cheap rather than a fresh run.

POST /v1/uploads

filemultipart/form-datarequired
The file itself. It is streamed rather than held in memory, so a large master is fine.
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 file's own SMPTE clock, when it has one. This is a different clock from the elapsed seconds used everywhere else. Both are true, and neither should be converted into the other.
size_bytes vs received_bytes
What we hold against what you sent. A large master is kept in a smaller working copy, and the video is still your master.

Get a signed upload URL that needs no key

This is how a file gets up from a machine the API cannot reach. Your server asks for a pass, the pass travels to wherever the file is, and the pass itself is the authorization. No key of yours ever leaves your own 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…",
  "direct_url": "https://api.tryplayhead.com/v1/uploads/direct?ticket=eyJvcmciOiJvcmdf…",
  "expires_at": "2026-08-06T18:20:00Z",
  "max_bytes": 2147483648
}

Send the bytes to storage instead of to us

Prefer this to POST /v1/uploads whenever your client can make two requests. It is faster for a large export, because the bytes go straight to storage rather than through the API. Call this, send the file to the address it names, then POST to complete_url. A large file comes back as a multipart target instead: one request per part, each answering with an ETag, and the list of tags goes to complete_url. Add ?ticket=… instead of a credential to use an upload pass, and the complete_url you get back already carries it.

POST /v1/uploads/direct

filenamestring
Decides the extension of the stored object and the title the video gets.
bytesinteger
The size you are about to send. Over 100 MB you get a multipart target, and over your account limit you get a refusal before any address exists. The file is measured again when you complete it.
content_typestring
Sent back in the headers you must use for the transfer.
Request: send the bytes to storage instead of to usbash
curl -X POST https://api.tryplayhead.com/v1/uploads/direct \
  -H "Authorization: Bearer $PLAYHEAD_API_KEY" \
  -H "content-type: application/json" \
  -d '{"filename":"cut-v4.mov","bytes":734003200,"content_type":"video/quicktime"}'
Responsejson
{
  "upload_id": "upl_01HZY6M8Q2",
  "max_bytes": 2147483648,
  "expires_at": "2026-08-06T18:20:00Z",
  "complete_url": "https://api.tryplayhead.com/v1/uploads/upl_01HZY6M8Q2/complete",
  "put": {
    "method": "PUT",
    "url": "https://storage.tryplayhead.com/uploads/org_…/upl_…/source.mov?X-Amz-…",
    "headers": { "content-type": "video/quicktime", "cache-control": "private, max-age=3600" }
  }
}

Fields worth reading

put vs multipart
Exactly one is present. put is a single request, and it must carry exactly the headers it names: the address is signed with them, so anything else is refused. multipart is one request per part.
complete_url
POST here once the file is up. It answers the same body POST /v1/uploads does, and it is safe to repeat: a client that retries after a timeout gets the same answer rather than an error.

Status and result of a queued job

status moves from queued to running, then to succeeded or failed. A failure carries the same error body a waiting 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": "transcript",
  "credits_charged": 12,
  "cache_hit": false,
  "queued_at": "2026-08-06T17:02:11Z",
  "finished_at": "2026-08-06T17:04:48Z",
  "error": null,
  "result": { "language": "en", "source": "transcribed", "text": "…" }
}

Balance, plan and ledger

credits_balance is the plan's monthly grant, and it is set again on credits_renew_at. topup_balance is API credit you bought, and nothing resets it. tool_balance is credit bought for the dashboard and the connector, and an API key cannot spend it. wallet says which pot this credential spends. On an API key it is always topup: the API is billed from API credit and cannot reach the plan's grant.

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": 126000,
  "tool_balance": 0,
  "wallet": "topup",
  "plan": {
    "monthly_credits": 2000,
    "concurrency": 4,
    "retention_days": 30,
    "max_video_minutes": 180,
    "allows_motion": true
  },
  "ledger": [
    { "delta": -1100, "reason": "watch_video", "job_id": "job_01HZY6M8Q2", "balance_after": 1840 }
  ]
}

Erase a video and everything derived from it

This is the erasure path, and it erases. After it returns the data is gone, and a later question about the same video is a fresh first look that is charged as one.

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.
How do I ask about a whole video rather than part of one?
Leave window out. Nothing is clipped by default, and an answer covers the video second zero to the end. Send a window only when you want to pay for part of it.
How long can one request take?
A first look at an hour of video is minutes of real work, and a call that waits for the answer waits for all of it. Pass sync false for anything long: the call returns a job_id at once, and GET /v1/jobs/{id} carries the result when it settles.
Am I charged twice for the same question?
No. The same question about the same video comes back from what was already found, and the answer says cached true. An idempotency_key goes further: replaying one returns the first answer rather than doing the work again.