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.
| Method | Path | What it does |
|---|---|---|
POST | /v1/watch_video | The 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/batches | The 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/transcript | Text only. No pictures are read, which makes this the cheapest call in the API by a wide margin. |
POST | /v1/uploads | For 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/tickets | A short-lived signed URL somebody else can POST bytes to without any credential of yours. |
POST | /v1/uploads/direct | A 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/credits | Two 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.
- stringrequired
- A link to the video, or an upl_… id from an upload. Required unless upload_id is given.
- string
- The id returned by POST /v1/uploads. Use instead of url.
- string
- What you want to know. Omit it for what happens across the complete video.
- string
- 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.
- string[]
- 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.
- object
- 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.
- number[]
- 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.
- stringdefault 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.
- object
- 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.
- string
- How the answer should read, in your words instead of ours. It changes the wording and nothing else.
- boolean
- Watch the whole video again and pay for it again. Ask without it first: the answer states the price and starts nothing.
- string
- Replaying the same key returns the first answer rather than charging twice.
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?"
}'{
"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
- Pass this with the next question about the same video. It is the difference between a few hundred tokens and a few thousand.
- 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.
- What the question asked for and the video does not show, named rather than guessed at. Each entry says which field and why.
- 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.
- True when this exact question had been answered before. It then costs you nothing.
- 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.
- object[]
- 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.
- string
- Asked of every item that does not carry its own.
- object
- A JSON Schema for every item. Each answer then comes back in value, in that shape.
- string
- How every answer should read. An item may override it.
- string
- watch_video, which is also the default and today the only one.
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" }
]
}'{
"batch_id": "bat_01HZY6M8Q2",
"verb": "watch_video",
"status": "queued",
"total": 3
}Fields worth reading
- 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.
- 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.
- number
- Seconds to hold the connection, 0 to 60. 0 answers at once with whatever is finished.
curl "https://api.tryplayhead.com/v1/batches/bat_01HZY6M8Q2?wait=60" \
-H "Authorization: Bearer $PLAYHEAD_API_KEY"{
"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
- How many items answered. failed says how many did not.
- The name you gave that item. Match answers by this, never by position.
- The shaped answer, when the batch or the item sent a schema.
- 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.
- stringrequired
- A link to the video, or an upl_… id from an upload. Required unless upload_id is given.
- string
- The id returned by POST /v1/uploads. Use instead of url.
- booleandefault false
- Include per-word timings.
- stringdefault auto
- Force a language, e.g. de.
- number | string
- Window start.
- number | string
- Window end.
- json | srt | vtt | textdefault json
- srt and vtt return a subtitle file in the subtitles field.
- booleandefault 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
- 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.
- 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.
- 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.
- multipart/form-datarequired
- The file itself. It is streamed rather than held in memory, so a large master is fine.
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
- 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.
- 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.
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…",
"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.
- string
- Decides the extension of the stored object and the title the video gets.
- integer
- 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.
- string
- Sent back in the headers you must use for the transfer.
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"}'{
"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
- 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.
- 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.
curl https://api.tryplayhead.com/v1/jobs/job_01HZY6M8Q2 \
-H "Authorization: Bearer $PLAYHEAD_API_KEY"{
"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.
- integerdefault 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": 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.
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.
- 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.