# headcast API

The whole of https://headcast.ai/docs in one file. Short version: https://headcast.ai/llms.txt

# API
Download
Every word of this page as one markdown file, for reading offline or handing to an agent: /docs.md. A shorter summary lives at /llms.txt.
Make talking-head videos from your own code. Same pricing as the app: your plan's minutes, plus packs of minutes at $1.00 each bought from the billing page. The API is on every plan above Starter.

## Quickstart
Everything below runs against https://headcast.ai. Get a key on the API page of your account first. headcast is invite-only; if you do not have an account yet, ask for an invite.
This block makes a presenter, waits for it, submits a video, waits for it and downloads the mp4. It needs a photo, a voice sample and a script file. The shell version also needs curl and jq. Pick a language once and every example on the page follows.
If you would rather build the JSON by hand, step 3 is just a POST of {"script": "...", "presenter": "ca-you-diane-1", "title": "Cast iron care"}. Every field is described under Videos.

## Start your agent with this
Paste this once at the top of your conversation with a coding agent, then say what you want built. It carries every fact and limit the agent needs, so it can write working code without reading this page.

```
You are working against the headcast API, which turns a written script into a finished
talking-head video. Read these facts, then do what I ask next. Do not guess at fields or
endpoints that are not listed here; if something is missing, ask me.

AUTH AND BASE
- Base URL: https://headcast.ai
- Every request: header "Authorization: Bearer $CHEAPAVATAR_KEY". Read the key from the
  environment, never hard-code it, and never put it in client-side code.
- One key per account, server side only. A machine-readable copy of this summary is at
  https://headcast.ai/llms.txt

ENDPOINTS (this is all of them)
- GET  /api/v1/me            -> {"email","account","minutes","plan","extra_usd_per_minute","max_inflight","max_queued","max_presenters"}
- GET  /api/v1/presenters    -> list, newest first
- POST /api/v1/presenters    -> 202 {"id","status"}; multipart form-data: name, image, audio
- POST /api/v1/videos        -> 202 video object; JSON {"script","presenter","title","setting","callback_url"}
- GET  /api/v1/videos        -> the 100 newest videos of the account, newest first
- GET  /api/v1/videos/{id}   -> one video object
- GET  /api/v1/videos/{id}/assets -> the working files kept from a finished render
- POST /api/ad-slot/match    -> {ok, words, seconds}; check a passage before you submit one

THE VIDEO OBJECT
  id, title, status, words, est_min, actual_min, minutes_charged, video_url, thumb_url,
  error, stage, progress, created_at, started_at, finished_at
- status: queued -> rendering -> done, or failed, or cancelled if you cancelled it, an operator
  stopped it or it ran past its time limit.
- cancel: POST /api/v1/videos/{id}/cancel while status is queued. Every video stays queued for at
  least 20 seconds after submit. Full refund. Once rendering it runs to the end (409 already_started).
- when done: video_url is a direct mp4 on https://headcast.ai and actual_min is the real
  length, which is what was charged. Until then actual_min is null.
- progress.percent is 0 to 99; stage is a plain sentence fit for a log line.

A PRESENTER IS ONE-TIME SETUP
- name, plus image (jpg, png or webp, at least 512px on the short side, one clearly visible
  face) and audio (8 to 30 seconds of that person speaking alone, no music).
  Uploads up to 12 MB.
- POST returns 202; poll GET /api/v1/presenters until that id has "status":"ready". Only a
  ready presenter can be used. Reuse the id forever. How many an account may hold comes from its
  plan (3 on Starter up to 50 on Max); read max_presenters from GET /api/v1/me.

SUBMITTING A VIDEO
- script: 30 to 6,000 words of plain prose. Strip markdown, headings, timestamps, beat
  markers and any references section first: those lines are removed before the words are
  counted and billed, so send only what should be spoken.
- presenter: a ready presenter id. title: optional, first line of the script is used if absent.
- setting: optional, one sentence about where the presenter is and what the video shows. It
  steers the pictures.
- broll: optional, who may appear in the b-roll: {"no_people": bool, "no_presenter": bool}.
  no_people = only the things being talked about, not even hands. no_presenter = the presenter
  is only ever on camera. Left out, the rule saved with the presenter applies.
- instructions: optional, your own note about what the video, or the whole channel, is about,
  up to 1500 characters. The planner draws that world and nothing beside it. Left out, the note
  saved with the presenter applies.
- callback_url: optional https URL on a public host.

POLLING
- Poll GET /api/v1/videos/{id} every 30 seconds. Never faster than every 10 seconds.
- For many videos, poll GET /api/v1/videos once instead of one call per video.
- about 40 minutes for a 10 minute video. Never block a user-facing request on a render.

LIMITS, ALL ENFORCED
- Videos rendering at once depend on the plan (Creator 2, Max 5); there is no
  queue beyond that, one more submit gets 429. Read max_inflight from GET /api/v1/me rather than assuming.
- 60 submissions an hour per account. 600 requests a minute per account and per IP.
- Build a queue that respects these, not a fan-out.

MONEY
- Plans come with minutes of finished video each month. Minutes are reserved when a video is
  accepted, estimated at 190 words a minute plus a quarter minute, and settled to the real length
  when it finishes. Minimum one minute. A failed video is refunded in full.
- A video is only accepted when "minutes" covers its estimate. Past that you get 402 with
  needed and balance in the body: stop and report it. More minutes are bought as packs from the
  billing page in the app, at $1.00 each (extra_usd_per_minute on GET /api/v1/me); they land
  on the balance at once and never expire.

ERRORS
- Shape: {"error": "a plain sentence"} with a normal HTTP status.
- 400 the request is wrong, 401 bad key, 402 not enough minutes, 404 no such thing,
  429 too fast or too many in flight, 5xx our side.
- Never retry 400, 401, 402 or 404. Retry 429 and 5xx after 60 seconds.
- There is no idempotency key. If a POST times out, do not resubmit blindly: call
  GET /api/v1/videos and look for a video with the same title from the last few minutes.

CALLBACKS, IF I ASK FOR THEM
- We POST to callback_url once the video reaches done or failed. Body:
  {"event":"video.done"|"video.failed","video":{ the video object }}.
- Header "X-Cheapavatar-Signature" is the hex HMAC-SHA256 of the exact raw body, keyed with my
  callback signing secret (env CHEAPAVATAR_WEBHOOK_SECRET). Verify against the raw bytes with a
  constant-time compare before parsing, and reply 401 if it does not match.
- Reply 2xx quickly. Three tries at most, 30 then 60 seconds apart, 15 second timeout, no
  redirects followed, no replay endpoint. Make the handler idempotent on the video id, and
  reconcile on start-up with GET /api/v1/videos.

HOW TO WRITE THE CODE
- Standard library only unless I say otherwise. Keys and presenter ids from the environment.
- Log the video id as soon as it is accepted, then one line per poll, then the mp4 URL.
- Keep a small state file so a stopped run never makes the same video twice.
```

Then ask for what you want. For example:

- Turn script.txt into a video and download the mp4 when it is done.

- Take every.txt in./scripts, make a video for each, and save the mp4s beside them.

- Add a video step to my existing pipeline, one video per finished article, with a state file.

- Stand up a webhook receiver that verifies the signature and files the finished videos.

- Tell me what my last twenty videos cost and how many minutes I have left.

## Agents
A short machine-readable summary of everything below lives at /llms.txt. Fetch that first if you are an agent deciding how to call this API.
The contract for machine callers. Everything here is what the service does today, not a plan.

### Every endpoint an API key can call

```
{
  "base_url": "https://headcast.ai",
  "auth": {"header": "Authorization", "format": "Bearer YOUR_KEY", "alternative": "X-API-Key: YOUR_KEY",
           "keys_per_account": 1, "scope": "full account access", "sessions_accepted": false},
  "endpoints": [
    {"method": "GET",    "path": "/api/v1/me",             "success": 200, "returns": "account"},
    {"method": "GET",    "path": "/api/v1/presenters",     "success": 200, "returns": "presenter[]"},
    {"method": "POST",   "path": "/api/v1/presenters",     "success": 202, "body": "multipart/form-data",
     "fields": {"name": "required", "image": "required file", "audio": "required file"},
     "returns": "{preset_id, id, status}"},
    {"method": "POST",   "path": "/api/v1/videos",         "success": 202, "body": "application/json",
     "fields": {"script": "required", "presenter": "required", "title": "optional",
                "setting": "optional", "callback_url": "optional https URL",
                "qr": "optional object, see /docs#videos",
                "broll": "optional {no_people, no_presenter}, see /docs#videos",
                "instructions": "optional text up to 1500 characters, see /docs#videos",
                "voice_token": "optional, from /api/v1/voiceovers: render your own recording, see /docs#voiceover"},
     "returns": "video"},
    {"method": "POST",   "path": "/api/v1/voiceovers",     "success": 200, "body": "multipart/form-data",
     "fields": {"audio": "required file, your own narration", "presenter": "optional, sets the language it is read in"},
     "returns": "{voice_token, expires_in, text, seconds, minutes, words, unsure[]} — nothing is charged"},
    {"method": "POST",   "path": "/api/v1/videos",         "success": 202, "body": "multipart/form-data",
     "fields": {"every JSON field, as form fields": "qr must be a JSON string",
                "audio": "optional file, your own narration read and rendered in one call; script becomes optional",
                "ad_clip": "optional file, your clip over one or more passages", "ad_text": "required with ad_clip, the passage, or up to 6 one per line",
                "ad_passages": "optional JSON array of passages instead of ad_text",
                "qr_image": "optional file, your own code instead of one made from a link"},
     "returns": "video"},
    {"method": "POST",   "path": "/api/ad-slot/match",     "success": 200, "body": "{script, passage|passages[]}",
     "returns": "{ok, start, end, marked, words, seconds} — check a passage before you submit"},
    {"method": "GET",    "path": "/api/qr.png?url=...",    "success": 200, "returns": "image/png, the code card a link would get"},
    {"method": "GET",    "path": "/api/v1/videos",         "success": 200, "returns": "video[] (100 newest, no paging)"},
    {"method": "GET",    "path": "/api/v1/videos/{id}",    "success": 200, "returns": "video"},
    {"method": "POST",   "path": "/api/v1/videos/{id}/cancel", "success": 200, "returns": "video (cancelled, minutes returned); 409 already_started once it is rendering"},
    {"method": "GET",    "path": "/api/v1/videos/{id}/assets", "success": 200, "returns": "{id, archived, assets[]} — the working files kept from the render"},

    {"method": "GET",    "path": "/api/presets/{id}/portrait",     "success": 200, "returns": "image/jpeg"},
    {"method": "GET",    "path": "/api/presenters/{id}/voice",     "success": 200, "returns": "audio/mpeg"},
    {"method": "POST",   "path": "/api/presenters/{id}/rename",    "success": 200, "body": "{name}", "returns": "{ok, name}"},
    {"method": "DELETE", "path": "/api/presenters/{id}",           "success": 200, "returns": "{ok}"},
    {"method": "POST",   "path": "/api/videos/{id}/retry",         "success": 202, "returns": "video (a NEW id)"},
    {"method": "DELETE", "path": "/api/videos/{id}",               "success": 200, "returns": "{ok}"},
    {"method": "GET",    "path": "/api/ledger",                    "success": 200, "returns": "ledger[]"},
    {"method": "GET",    "path": "/api/keys",                      "success": 200, "returns": "{keys}; webhook_secret only for a signed-in owner, never for a key"},
    {"method": "POST",   "path": "/api/portal",                    "success": 200, "returns": "{url} the billing page for a person to open (signed in only, not with a key)"}
  ],
  "note": "Paths under /api/v1/ are the versioned API. The rest are the app's own routes; a key may call them but they are not versioned.",
  "statuses": {"video": ["queued", "rendering", "done", "failed", "cancelled"],
               "presenter": ["checking", "queued", "cloning voice", "building presenter", "ready", "failed"]},
  "limits": {"words": "30 to 6,000", "title_chars": 120,
             "upload_mb": {"presenter": 12, "ad_clip": 100, "qr_image": 10, "voiceover": 100},
             "voiceover_seconds": "20 to 35 minutes", "voiceover_reads_per_hour": 20,
             "ad_clip_seconds": 90, "qr_size_pct": "5 to 40, default 14",
             "qr_every_min": "1 to 15", "qr_hold_sec": "3 to 60",
             "passage_min_words": 6,
             "qr_passage_max": 6,
             "presenters": "by plan, see /api/v1/me", "rendering_at_once": "by plan, 1 on Starter, 2 on Creator, 3 on Pro, 5 on Max", "queued_or_rendering": "same as rendering_at_once, no queue",
             "requests_per_minute": 600, "submits_per_hour": 60, "presenter_uploads_per_hour": 10},
  "money": {"unit": "minute", "plans": "Creator $299 for 391 min, Pro $699 for 977 min, Max $1,299 for 1,955 min a month; Starter $99 for 114 min has no API",
            "extra_usd_per_minute": "1.00 on every plan, bought up front as packs", "estimate_wpm": 190, "pad_min": 0.25,
            "min_charge_min": 1, "hold": "on submit", "settle": "on finish", "refund": "full on failed or cancelled"},
  "errors": {"shape": "{\"error\": \"...\"}", "400": "bad request", "401": "bad or missing key",
             "402": "not enough minutes", "404": "no such video or presenter", "413": "body too large",
             "429": "rate limit OR too many videos in flight", "503": "upload checker busy", "5xx": "our side"}
}
```

### Polling rules

- Poll a video every 30 seconds. Never faster than every 10 seconds. There is no long poll and no stream.

- To watch several videos, poll GET /api/v1/videos once, not one request per video. It returns the 100 newest.

- queued: nothing to do. queue_position and eta_min are on the object.

- rendering: progress.percent rises to 99 and eta_min counts down. Progress never goes backwards.

- done: video_url is the mp4, actual_min is the finished length and what was charged. Terminal.

- failed: error says why, in plain words. The minutes were refunded. Terminal.

- cancelled: you cancelled it before it started, an operator stopped it, or it ran past its time limit. Minutes refunded. Terminal.

- If a video sits in queued or rendering for more than three hours, keep polling anyway: a render is stopped and refunded automatically once it passes three hours, or 20 minutes for every estimated minute of video, whichever is longer.

### Idempotency
There is no idempotency key. Two identical POSTs make two videos and two holds. If a submit times out or you lose the response, do not resubmit: call GET /api/v1/videos and look for your title with a recent created_at. Store the returned id before you do anything else with it.
Callbacks can arrive more than once for the same video. Key your handler on video.id.

### Retry rules
| Response | Do |
| --- | --- |
| 400 | Never retry. Fix the request. The message names the problem. |
| 401 | Never retry. The key is missing, revoked, or the account is disabled. |
| 402 | Never retry. The balance does not cover the estimate. Happens on every plan. The body carries needed, balance and packs. |
| 403 | Never retry. The key's plan has no API access. Move to a plan above Starter. |
| 404 | Never retry. That id does not belong to this account. |
| 409 already_started | Never retry. The video you tried to cancel is already rendering and will run to the end. |
| 413 | Never retry. The body is too large: 12 MB for a presenter, 100 MB for a video carrying an ad clip, 100 MB for a voiceover. |
| 429 "too many requests; slow down" | Back off 60 seconds, then retry. |
| 429 "60 render requests an hour is the limit" | Wait until the hour has moved on. Do not spin. |
| 429 "you already have N videos queued or rendering..." | Backpressure, not an error. Wait for a video to finish, then submit again. |
| 503 "busy checking other uploads..." | Retry the presenter upload after a minute. |
| 500 | Retry once after a minute. Nothing was charged. |
| Timeout on a POST | Do not retry. List and reconcile, as above. |

A 429 can also come from the edge before it reaches the API, when one address sends more than 20 requests a second. That one is HTML, not JSON. Treat any 429 without a JSON body as "slow down".

### Error strings worth matching on
These are the exact strings the API returns. Match on them if you must branch, but prefer the status code.

```
400  "script is 12 words; minimum is 30"
400  "script is 9000 words; maximum is 6,000 (about 30 minutes)"
400  "pick a presenter that is ready"
400  "title must be 120 characters or fewer"
400  "callback_url must be an https URL"
400  "callback_url must point at a public host"
400  "give the presenter a name"
400  "both a portrait image and a voice sample are required"
400  "portrait must be a JPG, PNG or WEBP"
400  "voice sample must be an audio file (mp3, wav, m4a)"
400  "image is 400x400; it needs to be at least 512px on the short side"
400  "voice sample is 4s; it needs at least 8 seconds of clear speech"
400  "voice sample is 90s; keep it under 30 seconds"
400  "voice sample is nearly silent; record closer to the mic"
400  "you already have 10 presenters; this account allows 10"   (the number is the plan's)
401  "sign in or pass an API key"
402  "this video needs about 4.6 minutes and you have 1.2; buy minutes under Billing"   (code: no_minutes)
402  "add minutes or a plan before building a presenter"
403  "API access is on the Creator, Pro and Max plans"
404  "no such video"
404  "no such presenter"
404  "not found"
413  "upload too large (12 MB max for a presenter, 100 MB for a video with an ad clip)"
429  "too many requests; slow down"
429  "60 render requests an hour is the limit"
429  "10 presenter uploads an hour is the limit"
429  "you already have a video rendering; wait for it to finish before starting another"
400  "the ad passage was not found in the script; paste it exactly as it appears (6 words or more)"   (code: ad_no_match)
400  "upload the clip for the ad slot, or clear the passage"
400  "paste the passage of the script the ad clip should play over, or remove the clip"
400  "the ad clip could not be read; upload an mp4, mov or webm"
400  "the ad clip is 140s; keep it under 90 seconds"
400  "the QR link must be a web address, like example.com or https://example.com/page"   (code: qr)
400  "the QR size must be between 5% and 40% of the picture"   (code: qr)
400  "the QR position must sit inside the picture"   (code: qr)
400  "say when the QR code shows: over a passage, at intervals, or the whole video"   (code: qr)
400  "the QR passage was not found in the script; paste it exactly as it appears (6 words or more)"   (code: qr)
400  "mark at most 6 stretches of the script for the QR code"   (code: qr)
400  "show the QR every 1 to 15 minutes, for 3 to 60 seconds"   (code: qr)
400  "upload your QR code image, or switch back to making one from a link"   (code: qr)
400  "that code image could not be read; upload a png, jpg or webp"   (code: qr)
400  "upload an mp3, m4a, wav or aac"
400  "that file is 12 seconds. A video needs at least 20 seconds of speech."   (code: audio)
400  "we can hear more than one person. One presenter reads one voice, so a conversation will not work here."   (code: speakers)
400  "most of that file is not speech. If there is music under the voice it confuses the timing and it would play under the whole video, so upload the voice on its own."   (code: music)
400  "that recording has expired; upload it again"   (code: voice_expired)
400  "that script is not what the recording says; send the transcript from /api/v1/voiceovers with only the misheard words corrected, or leave script out to use what we heard"   (code: voice_script_mismatch)
400  "send the recording as audio or as a voice_token, not both"   (code: voice)
429  "20 uploads an hour is the limit"   (reading voiceovers)
503  "busy checking other uploads; try again in a minute"
500  "something went wrong on our side; try again in a minute"
```

On a failed video the error field on the object is one of exactly three sentences: the render could not start; your minutes were returned, the finished video failed its quality check; your minutes were returned, or the render failed; your minutes were returned. A cancelled video carries the operator's reason, or the render took too long and was stopped; your minutes were returned.

### What the API will never do

- A video that has started cannot be cancelled. You can cancel it while it is queued, which is always at least its first 20 seconds; after that it runs to the end, and only an operator can stop one; email hello@headcast.ai and we will stop it and return the minutes.

- A finished video cannot be deleted through the API. DELETE /api/videos/{id} answers 400 only a failed or stopped video can be removed for anything else.

- A presenter cannot be removed while one of its videos is queued or rendering, or while it is still being built.

- Nothing about a submitted video can be edited: not the script, not the title, not the presenter, not the callback URL. Submit a new one.

- There is no paging, filtering, sorting or search on GET /api/v1/videos. It is the 100 newest, newest first.

- There is no callback for a presenter becoming ready. Poll for that one.

- There is no second key. POST /api/keys answers 400 one key per account; revoke the current one first while a key exists.

- Browser sessions are not accepted on /api/v1/. Keys only.

- The API never charges a card by itself unless you switched automatic top-ups on in the app.

## Tool definitions
Drop these into your own agent tooling. Parameter names and requirements match the API exactly.

```
[
  {
    "name": "create_presenter",
    "description": "Create a reusable presenter from a portrait and a voice sample. Returns immediately with an id; the presenter is not usable until its status is 'ready' (poll list_presenters).",
    "http": {"method": "POST", "url": "https://headcast.ai/api/v1/presenters", "encoding": "multipart/form-data", "success": 202},
    "input_schema": {
      "type": "object",
      "properties": {
        "name": {"type": "string", "minLength": 2, "maxLength": 60, "description": "Display name for the presenter."},
        "image": {"type": "string", "format": "file", "description": "Portrait file, .jpg .jpeg .png or .webp. One clearly visible front-facing face, at least 512px on the short side, at most 4096px on the long side."},
        "audio": {"type": "string", "format": "file", "description": "Voice sample, .mp3 .wav .m4a .ogg .mp4 .webm .aac or .flac. 8 to 30 seconds of that person speaking alone, no music."}
      },
      "required": ["name", "image", "audio"]
    },
    "returns": {"preset_id": "string", "id": "string (same value)", "status": "string, always 'queued'"}
  },
  {
    "name": "list_presenters",
    "description": "Every presenter on the account, newest first. Use it to wait for status 'ready'.",
    "http": {"method": "GET", "url": "https://headcast.ai/api/v1/presenters", "success": 200},
    "input_schema": {"type": "object", "properties": {}, "required": []},
    "returns": "array of {id, name, status, error, created_at, wpm, voice_sample}"
  },
  {
    "name": "create_video",
    "description": "Submit a script to be rendered as a talking-head video. Returns immediately; the render takes tens of minutes. Minutes are held from the account balance on submit.",
    "http": {"method": "POST", "url": "https://headcast.ai/api/v1/videos", "encoding": "application/json or multipart/form-data when a file is attached", "success": 202},
    "input_schema": {
      "type": "object",
      "properties": {
        "script": {"type": "string", "description": "The words the presenter says, plain text, 30 to 6,000 words. Markdown headings, dividers, timestamps, beat lines and a trailing references section are stripped before the count."},
        "presenter": {"type": "string", "description": "A presenter id whose status is 'ready'. Also accepted as 'preset'."},
        "title": {"type": "string", "maxLength": 120, "description": "Optional. Defaults to the first line of the script, cut at 80 characters."},
        "setting": {"type": "string", "maxLength": 300, "description": "Optional. One sentence about where the presenter is and what the video shows. Steers the visuals."},
        "broll": {"type": "object", "description": "Optional. Who may appear in the b-roll, the pictures between the presenter's on-camera moments. {no_people: true} shows only the things being talked about, with no person and no hands. {no_presenter: true} keeps the presenter on camera only while other people may still appear. Sent with a video it applies to that video; left out, the rule saved with the presenter applies. Both false, or null, means anyone."},
        "instructions": {"type": "string", "maxLength": 1500, "description": "Optional. Your own note about what this video, or the whole channel, is about and what may or may not appear: the topic, the era, the place, the things to keep out. The planner treats it as the rule it plans the pictures by, above what it would guess from the script alone. Sent with a video it applies to that video, even when empty; left out, the note saved with the presenter applies."},
        "qr": {"type": "object", "description": "Optional. Puts a scannable code over the picture. Either make one from a link with {url}, or send your own image as multipart qr_image with {source: 'upload'}. Say when it shows with at least one of: passage (the sentences of the script it sits over, copied exactly), passages (up to 6 of those, each its own showing), every_min + hold_sec, or always. Place it with x and y as fractions of the frame for its top-left corner, or with position ('tr','tl','br','bl'). size_pct is its width as a percentage of the picture, 5 to 40, default 14."},
        "ad_text": {"type": "string", "description": "Optional, multipart only, and only with ad_clip. One passage of the script, copied exactly as it appears, 6 words or more. The uploaded clip plays over exactly those words while the narration keeps running underneath."},
        "ad_clip": {"type": "string", "format": "file", "description": "Optional, multipart only, and only with ad_text. .mp4 .mov .webm up to 90 seconds and 100 MB, or a .jpg or .png. Cut to the passage, or its last frame held if it is shorter."},
        "qr_image": {"type": "string", "format": "file", "description": "Optional, multipart only. Your own code image, .png .jpg or .webp, at least 80 pixels square and under 10 MB. A see-through background is flattened onto white. Send qr with source 'upload'."},
        "callback_url": {"type": "string", "format": "uri", "description": "Optional. https URL on a publicly resolvable host. Receives a signed POST when the video reaches done, failed or cancelled."}
      },
      "required": ["script", "presenter"]
    },
    "returns": "video object"
  },
  {
    "name": "get_video",
    "description": "One video by id, including status and progress. Poll every 30 seconds.",
    "http": {"method": "GET", "url": "https://headcast.ai/api/v1/videos/{id}", "success": 200},
    "input_schema": {
      "type": "object",
      "properties": {"id": {"type": "string", "description": "The video id returned by create_video, in the form 20260908-101500-3fa2b1."}},
      "required": ["id"]
    },
    "returns": "video object"
  },
  {
    "name": "list_videos",
    "description": "The 100 newest videos on the account, newest first. No paging, filtering or sorting. Use this rather than one get_video per video when watching a batch.",
    "http": {"method": "GET", "url": "https://headcast.ai/api/v1/videos", "success": 200},
    "input_schema": {"type": "object", "properties": {}, "required": []},
    "returns": "array of video objects"
  },
  {
    "name": "get_account",
    "description": "Minutes left and the account's limits. Read the limits, do not assume them.",
    "http": {"method": "GET", "url": "https://headcast.ai/api/v1/me", "success": 200},
    "input_schema": {"type": "object", "properties": {}, "required": []},
    "returns": {"email": "string", "account": "string", "minutes": "number",
                "max_inflight": "integer", "max_queued": "integer", "max_presenters": "integer"}
  }
]
```

## Authentication
Create a key on the API page of your account. Keys start with ca_ and are shown once. Send the key as a bearer token, or in an X-API-Key header if that suits your client better. Every request goes to https://headcast.ai. The versioned API accepts keys only, not browser sessions.
GET /api/v1/me 200
The minutes left on the account and the limits it runs under. Use it as the check that a key works.
Response:

```
{"email": "you@example.com", "account": "you", "minutes": 42.5,
 "plan": "creator", "extra_usd_per_minute": 1.00,
 "max_inflight": 2, "max_queued": 2, "max_presenters": 10}
```
plan is the plan id (starter, creator, pro or max), or beta when there is no live plan (a beta account on granted minutes, or one whose plan has lapsed). max_queued is the same number as max_inflight: there is no queue beyond what is rendering.
One key per account, with full access to it. Keep it on a server, never in a browser or an app you ship. To roll it, revoke the old one on the API page and create another. A missing, wrong or revoked key gets 401 {"error": "sign in or pass an API key"}.

## Presenters
A presenter is one person: a portrait and a cloned voice, rendered with HeyGen Avatar IV. Make one once and reuse it for every video. The portrait should be a clear, front-facing photo of one person. The voice sample is 8 to 30 seconds of that person speaking on their own, with no music.
GET /api/v1/presenters 200
Every presenter on the account, newest first. Wait here for status to read ready.

```
[{"id": "ca-you-diane-1", "name": "Diane", "status": "ready", "error": "",
  "created_at": 1757300000, "wpm": 178, "videos": 12, "voice_sample": true,
  "voice": {"rate": 0.9, "expressiveness": 1.1, "wpm": 183, "set": true},
  "language": "English", "bio": {}, "by": "",
  "defaults": {"ad_text": "", "ad_name": "", "ad_size": 0, "has_ad": false,
               "qr": null, "has_qr_image": false, "broll": null, "instructions": ""}}]
```
| Field | What it is |
| --- | --- |
| id | What you pass as presenter when you submit a video. |
| status | checking, queued, cloning voice, building presenter, ready or failed. Only ready can be used. |
| error | Empty unless the build failed. |
| created_at | Unix seconds. |
| wpm | This presenter's pace as used for estimates. Measured from their finished videos of two minutes or more, or from their voice directly once voice.set is true. null until one of those exists, and estimates fall back to 190. |
| voice | How this presenter reads. rate is the speaking pace, 0.75 to 1.15. expressiveness is how varied the delivery is, 0.4 to 1.9. wpm is what those settings measured, 0 if never measured. set is false on a presenter that carries no settings at all, which renders exactly as the voice engine reads by default. New presenters are built at a slightly slower pace than the engine's own, because a clone reads faster than the person it was built from in about three quarters of cases. |
| videos | How many finished videos this presenter has read. |
| voice_sample | Whether the cleaned voice clip is still on file for playback. |
| language | The language this presenter reads in, by name. English unless it was set in the app. |
| defaults.broll | Who may appear in this presenter's b-roll unless a video says otherwise: null for anyone, or {"no_people": bool, "no_presenter": bool}. Set it with POST /api/v1/presenters/{id}/defaults and a JSON body of {"broll": {...}}, or {"broll": null} to go back to anyone. The reply is the presenter's defaults block. |
| defaults.instructions | What this presenter's videos are about, in your words, unless a video says otherwise: the topic, the world, what must or must not appear. Up to 1500 characters, "" for none. Set it with POST /api/v1/presenters/{id}/defaults and a JSON body of {"instructions": "..."}, or {"instructions": ""} to clear it. |
| bio, the rest of defaults, by | The app's own fields: the character bible, the ad slot and QR code saved with the presenter, and whose login made it. The API does not set them. |

POST /api/v1/presenters 202
Create a presenter from a portrait and a voice sample. It returns straight away; the build runs in the background.
Multipart form with name, image and audio. The file type is read from the filename:.jpg,.jpeg,.png or.webp for the image;.mp3,.wav,.m4a,.ogg,.mp4,.webm,.aac or.flac for the audio. The whole request must be under 12 MB.

```
{"preset_id": "ca-you-diane-1", "id": "ca-you-diane-1", "status": "queued"}
```
The photo and the sample are checked while you wait, so a bad one comes straight back as 400 with the reason: under 512px on the short side, over 4096px on the long side, unreadable, sample too short, too long, or nearly silent. The voice clone and the presenter build then run in the background; poll GET /api/v1/presenters until the id reads ready. How many presenters an account may hold comes from its plan — 3 on Starter, 10 on Creator, 25 on Pro, 50 on Max — counting every one that has not failed. max_presenters on GET /api/v1/me is the number for this account.
Two more routes take a presenter id: GET /api/presets/{id}/portrait returns the stored portrait as image/jpeg, and GET /api/presenters/{id}/voice returns the cleaned voice sample as audio/mpeg. POST /api/presenters/{id}/rename with {"name": "..."} renames one, and DELETE /api/presenters/{id} removes one that is not building and has no video queued or rendering.
POST /api/v1/presenters/{id}/voice 200
Set how this presenter reads, then measure what that produced. The reply is the presenter's voice block with the new figures. It takes a few seconds, because the measurement is a real read rather than a calculation.
| Field | Required | What it is |
| --- | --- | --- |
| rate | no | Speaking pace, 0.75 to 1.15. Values outside that are pulled to the nearest end. 1.0 is the voice engine's own pace, which is faster than most people who were cloned. |
| expressiveness | no | How varied the delivery is, 0.4 to 1.9. It does not change the pace. |

Sending 1.0 and 1.1 together clears the settings rather than storing them, so the presenter goes back to carrying none at all and renders are told nothing.

```
{"ok": true, "voice": {"rate": 0.88, "expressiveness": 1.1, "wpm": 179, "set": true}}
```
POST /api/v1/presenters/{id}/voice/preview 200
Hear a setting before you keep it. Same body as above, and it changes nothing. The reply is audio/mpeg: a short fixed passage read in this presenter's voice at those settings, about fourteen seconds at pace 1.0. The pace it came out at is in the X-Words-Per-Minute header.
That figure describes the speech itself. A finished video runs about a tenth slower, because it carries gaps between beats and a fixed opening, so use it to compare one setting against another rather than to predict how long a video will be.
POST /api/v1/presenters/{id}/portrait 200
Give an existing presenter a new face. The voice is untouched and videos already made do not change; the next video uses the new photo.
Multipart form with one field, image, under 20 MB. The same checks as building a presenter apply: at least 512px on the short side, at most 4096px on the long side, readable as a JPG or PNG. The photo is used as given, so frame it as you want it to appear, 16:9.

```
{"ok": true}
```
POST /api/v1/presenters/{id}/voice/sample 200
Give an existing presenter a new voice. The portrait is untouched and videos already made do not change; the next video uses the new voice. The call returns once the new voice is ready, usually within half a minute.
Multipart form with one field, audio: 8 to 30 seconds of the person speaking on their own, with no music, as mp3, wav, m4a, aac, ogg, flac or webm. The same checks as building a presenter apply. Pace and expressiveness go back to where a new presenter starts, because a setting tuned to the old voice does not carry over; the response holds the new settings, and POST /api/v1/presenters/{id}/voice changes them. Ten voice changes a day per account.

```
{"ok": true, "voice": {"rate": 0.9, "expressiveness": 1.1, "wpm": 176, "set": true}}
```

## Videos
POST /api/v1/videos 202
Submit a script to be rendered as a talking-head video.
JSON body, or multipart/form-data when you attach a file: the same fields as form fields, with qr as a JSON string. Minutes are held when the video is accepted and settled to the real length when it finishes.
| Field | Required | What it is |
| --- | --- | --- |
| script | yes | The words the presenter says, as plain text. Between 30 and 6,000 words; the top end is about 30 minutes of speech. |
| presenter | yes | A presenter id with status ready. preset is accepted as the same field, and it comes back on the video as preset. |
| title | no | Shown in your list, 120 characters at most. Defaults to the first line of the script, cut at 80 characters. |
| setting | no | One sentence about where the presenter is and what the video is about. Guides the visuals. 300 characters at most. |
| callback_url | no | An https URL on a public host. We POST there when the video finishes, fails or is stopped. See callbacks. |
| qr | no | An object that puts a scannable code over the picture. Every field is in the QR note below. |
| ad_clip + ad_text | no | Multipart only. Your own clip over one or more passages of the script. Both or neither. See the ad slot note below. |
| qr_image | no | Multipart only. Your own code image instead of one made from a link. Send qr with "source": "upload". |
| voice_token | no | Render your own recording instead of the presenter's voice. The token comes from POST /api/v1/voiceovers. See your own voiceover. |
| audio | no | Multipart only. Your own recording, read and rendered in the same call. script becomes optional. See your own voiceover. |

Ad slot. A video can carry your own clip or image over one passage of the script, with the narration running underneath. Send the request as multipart/form-data with an ad_clip file (mp4, mov or webm up to 90 seconds and 100 MB, or a jpg or png) and an ad_text field holding that passage, copied from the script exactly as it appears, six words or more. The clip is cut to the passage, or its last frame is held if it is shorter. Send both or neither.
Several slots: to bring the same clip or image back at more than one point, put up to six passages in ad_text, one per line, or send them as ad_passages, a JSON array of strings. Each is matched on its own and they may not overlap. The clip starts from the beginning at every passage.

```
# an ad slot and your own QR code, over the API
curl -X POST https://headcast.ai/api/v1/videos \
  -H "Authorization: Bearer ca_your_key" \
  -F "presenter=ca-you-diane-1" \
  -F "title=Cast iron care" \
  -F "script=$(cat script.txt)" \
  -F "ad_text=scan the code on screen now and grab the free guide" \
  -F "ad_clip=@promo.mp4;type=video/mp4" \
  -F 'qr={"source":"upload","always":true,"x":0.06,"y":0.62,"size_pct":22}' \
  -F "qr_image=@mycode.png;type=image/png"
```

QR code. Pass qr to put a scannable code over the picture: {"url": "https://yoursite.com", "position": "tr", "size_pct": 14, "passage": "the sentences of the pitch", "every_min": 3, "hold_sec": 10, "always": false}. The code shows while the passage is spoken, every every_min minutes for hold_sec seconds, or for the whole video with always.
Placement: position is a corner (tr, tl, br, bl), or give x and y as fractions of the frame for the code's top-left corner and put it anywhere. size_pct is its width as a percentage of the picture, 5 to 40, default 14.
Your own code: send the request as multipart/form-data with a qr_image file (png, jpg or webp, at least 80 pixels square, under 10 MB) and qr as a JSON string with "source": "upload". A see-through background is flattened onto white so the code still scans over a dark shot.

The script is cleaned before it is counted, billed and spoken. Markdown headings, divider lines, bold and italic marks, Beat 3 lines, timestamp lines and anything from a References or Sources heading onwards are removed. Send the narration only, and the word count you see back in words is what was kept.

Response 202. This is the video object, and every other video route returns the same shape:

```
{"job_id": "20260908-101500-3fa2b1", "id": "20260908-101500-3fa2b1",
 "title": "Cast iron care", "preset": "ca-you-diane-1", "words": 832,
 "status": "queued", "stage": "starting",
 "est_min": 4.63, "actual_min": null, "minutes_charged": 4.63,
 "queue_position": 1, "eta_min": 35,
 "video_url": "", "thumb_url": "", "error": "", "progress": null,
 "ad_slot": false, "qr": false, "attempts": 0, "resumable": false, "by": "",
 "created_at": 1757326500, "started_at": null, "finished_at": null}
```
| Field | What it is |
| --- | --- |
| id, job_id | The same string. Use id. |
| status | queued, rendering, done, failed or cancelled. |
| stage | A short sentence for a person to read. While queued it is starting, waiting for a render slot or waiting for your other video to finish. While rendering it is the phase label and its detail, such as Making the visuals: 18 of 34 stills, 3 of 9 clips. Do not branch on it. |
| words | The words that will be spoken, after cleaning. This is what the estimate was based on. |
| est_min | The estimate, and the size of the hold. |
| actual_min | null until the video is done, then the finished length in minutes, which is what was charged. |
| minutes_charged | What the account is out by right now: est_min while queued or rendering, actual_min when done, 0 when failed or cancelled. |
| queue_position, eta_min | Only while queued. eta_min alone stays while rendering, counting down. |
| video_url | Empty until done, then a direct mp4 link on headcast.ai. 1080p, H.264, 16:9. |
| thumb_url | Empty until done, then a JPEG frame from about three seconds in, 640px wide. |
| error | Empty unless the video failed or was stopped. |
| ad_slot, qr | Whether this video carries your own clip over a passage, and whether it carries a QR code. Set from what you sent. |
| attempts | How many times this video has been sent to the render box, counting the first. |
| resumable | Whether a failed or stopped video still has cached work an operator could revive it from. Nothing an API caller acts on; use POST /api/videos/{id}/retry. |
| by | Whose login started it on a team account. Empty on a one-person account and on anything submitted with a key. |
| created_at, started_at, finished_at | Unix seconds. The last two are null until they happen. |

### Your own voiceover
Send a recording of the narration and the presenter lip-syncs to it instead of reading the script in their cloned voice. The words are read from the recording itself, with the time of each one, and that transcript becomes the script the shots are planned from.

- One voice, speaking on its own: no music bed and no second speaker. A file that is mostly music or silence is refused, and so is a conversation.

- 20 seconds to 35 minutes long, mp3, m4a, wav, aac, ogg, flac, webm or mp4 audio, up to 100 MB.

- The language is the presenter's. Pass presenter when you upload so a Spanish presenter's recording is read as Spanish.

- The hold is the recording's own length rather than an estimate from the word count, and the video settles to its finished length like any other.

- Reading is free and nothing is held until you submit the video. You can read 20 recordings an hour.
There are two ways to send one. Both use the same checks.
POST /api/v1/voiceovers 200
Read it first, then submit. Upload the recording as the multipart field audio. The answer is what we heard: check text, and especially the words listed in unsure. Then submit the video with voice_token and script set to that text, correcting any misheard words. The token lasts expires_in seconds, a day; after that the submit answers 400 with code voice_expired.

```
curl https://headcast.ai/api/v1/voiceovers \
  -H "Authorization: Bearer ca_your_key" \
  -F presenter=ca-you-diane-1 \
  -F audio=@narration.mp3
```

```
{"voice_token": "9f2c41d07a3be511", "token": "9f2c41d07a3be511", "expires_in": 86400,
 "text": "Go out to the garage and find the pan you gave up on...", "seconds": 312.4, "minutes": 5.21,
 "words": 861, "unsure": [{"word": "Lodge", "at": 41.2, "index": 118}],
 "name": "narration.mp3", "bytes": 5012233, "balance": 240.0}
```

```
curl https://headcast.ai/api/v1/videos \
  -H "Authorization: Bearer ca_your_key" \
  -H "Content-Type: application/json" \
  -d '{"presenter": "ca-you-diane-1", "voice_token": "9f2c41d07a3be511", "script": "Go out to the garage and find the pan you gave up on..."}'
```

All in one call. Attach the recording as audio on a multipart POST /api/v1/videos. Leave script out and the transcript is used as it was heard. The 202 answer is the video object plus a voiceover object holding seconds, words and unsure. The call waits for the recording to be read, which takes a few seconds even for a twenty minute file, plus the time to upload it. If the call times out, do not resubmit: list your videos and reconcile, as the idempotency rule above says.

```
curl https://headcast.ai/api/v1/videos \
  -H "Authorization: Bearer ca_your_key" \
  -F presenter=ca-you-diane-1 \
  -F "title=Cast iron care" \
  -F audio=@narration.mp3
```

A script sent with a recording must be what the recording says. Fix misheard words, spelling and punctuation freely. A different take, a rewritten paragraph or a missing section cannot be timed against the audio, so the submit is refused with code voice_script_mismatch before anything is held, and the token stays usable for a corrected try. The ad slot and QR passages are matched against that script, as usual.

POST /api/ad-slot/match 200
Check a passage against a script before you submit, so a mistyped one is caught without spending a request on the render queue. Body {"script": "...", "passage": "..."}. Answers {"ok": true, "start": 81, "end": 96, "words": 15, "seconds": 4.7} with the word range it matched, or {"ok": false, "error": "..."}. The same matcher runs again at submit, so a passage that matches here will match there. Used for both the ad slot and the QR passage.

### progress
progress is null until rendering starts.

```
"progress": {
  "percent": 46,
  "phase": "broll",
  "phase_label": "Making the visuals",
  "phase_detail": "",
  "phases": {
    "plan":     {"status": "done",    "detail": ""},
    "voice":    {"status": "done",    "detail": "", "started": "2026-09-08T10:18:02", "finished": "2026-09-08T10:21:40"},
    "broll":    {"status": "running", "detail": "", "started": "2026-09-08T10:21:41", "finished": null},
    "avatar":   {"status": "pending", "detail": "", "started": null, "finished": null},
    "assemble": {"status": "pending", "detail": "", "started": null, "finished": null},
    "package":  {"status": "pending", "detail": "", "started": null, "finished": null},
    "upload":   {"status": "pending", "detail": ""}
  }
}
```
| Key | What it is |
| --- | --- |
| percent | 0 to 99. It never reaches 100 and never goes backwards; status is what tells you it finished. |
| phase | The phase the render is in: plan, voice, broll, avatar, assemble, package or upload. |
| phase_label | That phase in words: Planning the shots, Recording the voice, Making the visuals, Filming the presenter, Editing it together, Checking quality, Uploading. |
| phase_detail | Always an empty string over the API. The counts behind it are internal; stage is the line written for a person to read. |
| phases | All seven phases, in that order, each with a status of pending, queued, running or done, and a detail that is always empty for the same reason. The five middle phases also carry started and finished as ISO 8601 strings or null. |

GET /api/v1/videos 200
Your 100 newest videos, newest first, each the same shape as above. No paging, no filters.
GET /api/v1/videos/{id} 200
One video. Poll every 30 seconds. An id that is not yours answers 404 {"error": "no such video"}.
POST /api/v1/videos/{id}/cancel 200
Take a video back before it starts. Every video waits at least 20 seconds after submit, and longer if it is queued behind others; until it starts, cancel returns the video with status cancelled and every held minute comes back. Once it is rendering the answer is 409 {"error": "this video has already started, so it can no longer be cancelled", "code": "already_started"} and it runs to the end. Check the presenter before you submit: the video is made with the one you name. If something is wrong with a started video, email hello@headcast.ai.
GET /api/v1/videos/{id}/assets 200
Every working file kept from the render: {"id": "...", "archived": true, "assets": [{"kind": "still", "path": "assets/beat-3.jpg", "bytes": 214880, "url": "https://headcast.ai/v/..."}]}. kind is one of still, clip, avatar, avatar_audio, avatar_lead, avatar_meta, split, voiceover, ad, character, plan, timeline, qa, log, final or other. The list is empty until the render is archived, which happens after it finishes.

### After a failure
POST /api/videos/{id}/retry runs a failed or stopped video again with the same script, title, presenter, setting and callback URL. It answers 202 with a new video object and a new id, and it takes a new hold. Anything else answers 400 {"error": "only a failed or stopped video can be tried again"}. If the video failed long ago its script may no longer be kept, and you get 400 saying so; submit it again instead.
DELETE /api/videos/{id} removes a failed or stopped video and its log for good. A queued, rendering or finished video cannot be deleted.

## Callbacks
If you gave a callback_url, we POST a JSON body to it when the video reaches done, failed or cancelled.

```
POST your URL
Content-Type: application/json
User-Agent: cheapavatar-webhook/1
X-Cheapavatar-Signature: 9f2c...   (64 hex characters)

{"event": "video.done", "video": { ...the same object GET /api/v1/videos/{id} returns... }}
```
The event is video.done, video.failed or video.cancelled. The signature is the hex HMAC-SHA256 of the raw body, keyed with the callback signing secret shown on the API page. Check it against the exact bytes you received, before parsing, then act on the payload or simply re-fetch the video by id. You can rotate the secret on that page at any time; the next callback uses the new one.

```
import hmac, hashlib

def signature_ok(raw_body: bytes, header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

# raw_body is the request body exactly as received; header is request.headers["X-Cheapavatar-Signature"]
```

Delivery: up to three tries, waiting 30 seconds after the first failure and 60 after the second, with a 15 second timeout each. Any 2xx counts as delivered. Redirects are not followed and are treated as a failure. The URL must still resolve to a public address at delivery time. There is no replay endpoint, so treat callbacks as a nudge and reconcile with GET /api/v1/videos when your receiver starts up.

## Billing and limits

- From $99 a month. Every plan comes with minutes of finished video that reset monthly. More minutes come as packs bought from the billing page at $1.00 each, the same on every plan; bought minutes never expire.

- The estimate is the word count at 190 words a minute, or the presenter's own measured pace once we have it, plus a quarter of a minute for the intro and outro. A measured pace is only trusted between 140 and 230 words a minute.

- That estimate is held when the video is accepted. When the video finishes, the hold is replaced by the real length of the mp4, so you pay for what you got. Every finished video bills at least one minute.

- Failed and stopped videos are refunded in full. A render that has not finished after three hours, or after 20 minutes for every estimated minute of video if that is longer, is stopped and refunded.

- A submit is refused with 402 when the balance is below the estimate, on every plan; the body carries needed, balance and the packs on offer. Buy minutes under Billing in the app and try again.

- Videos rendering at once come with the plan: 1 on Starter, 2 on Creator, 3 on Pro, 5 on Max. There is no queue beyond that: one more submit gets 429 until one finishes.

- Presenters per account come with the plan: 3 on Starter, 10 on Creator, 25 on Pro, 50 on Max.

- Request limits: 600 requests a minute per account and per address, 60 video submissions an hour, 10 presenter uploads an hour, 12 MB per request.

- Plan on about 40 minutes for a 10 minute video. Longer scripts take proportionally longer.

- GET /api/ledger returns the account's money lines, newest first: kind is plan (a month of minutes), grant, hold, settle, refund, expire (unused minutes at the month's end), overage (extra minutes billed), clawback or purchase (a pack of minutes bought), delta_min is the signed change in minutes, and ref is the video id when there is one.

## Errors
Errors are JSON with a plain-language error field. A 402 also carries needed and balance. Some 400 s also carry a short code worth branching on: ad_no_match when the ad passage is not in the script, and qr for anything wrong in the QR settings, voice_script_mismatch when a script is not what its recording says, and voice_expired when a voice_token is older than a day or unknown. A short submit carries no_minutes on its 402.

```
{"error": "script is 12 words; minimum is 30"}
```
| Code | Meaning |
| --- | --- |
| 400 | Something in the request is wrong. The message says what. Never worth retrying. |
| 401 | Missing, wrong or revoked key, or a disabled account. |
| 402 | Not enough minutes on the account. |
| 403 | The key is on a plan without API access. Starter has none; every plan above it does. |
| 404 | No such video or presenter on this account, or no such route. |
| 413 | The request body is too large. A presenter upload may be 12 MB, a video carrying an ad clip 100 MB, a voiceover 100 MB, and a QR image 10 MB. |
| 429 | Three different things: more than 600 requests a minute, more than 60 submissions or 10 presenter uploads an hour, or already as many videos queued or rendering as the plan allows (1 on Starter up to 5 on Max). Tell them apart by the message. |
| 503 | Both upload checkers are busy. Try the presenter upload again in a minute. |
| 500 | Our fault. Retry in a minute; nothing was charged. |

The exact strings are listed under Agents.
