# headcast API

The whole of https://staging.headcast.ai/docs in one file. Short version: https://staging.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://staging.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://staging.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://staging.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

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. A video cannot be cancelled once submitted.
- when done: video_url is a direct mp4 on https://staging.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; up to 50 per account.

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.
- 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://staging.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"},
     "returns": "video"},
    {"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",
                "ad_clip": "optional file, your clip over one passage", "ad_text": "required with ad_clip, that passage",
                "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": "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}"},
    {"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},
             "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 340 min, Pro $699 for 850 min, Max $1,299 for 1,700 min a month; Starter $99 for 99 min has no API",
            "extra_usd_per_minute": "1.00 on every plan, billed monthly", "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: 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 account has no live plan and not enough minutes. The body carries needed and balance. |
| 404 | Never retry. That id does not belong to this account. |
| 413 | Never retry. The body is too large: 12 MB for a presenter, 100 MB for a video carrying an ad clip. |
| 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 50 presenters; this account allows 50"
401  "sign in or pass an API key"
402  "this video needs about 4.6 minutes and you have 1.2"   (accounts without a live plan only)
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)
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

- There is no cancel. A submitted video runs to the end. Only an operator can stop one; email hello@staging.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://staging.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://staging.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://staging.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."},
        "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://staging.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://staging.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://staging.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://staging.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,
 "max_inflight": 1, "max_queued": 1, "max_presenters": 50}
```
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, "voice_sample": true}]
```
| 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 measured speaking pace, once they have finished videos of two minutes or more. null until then, and estimates fall back to 190. |
| voice_sample | Whether the cleaned voice clip is still on file for playback. |

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. Up to 50 presenters per account, counting every one that has not failed.
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.

## 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 passage 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". |

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.

```
# an ad slot and your own QR code, over the API
curl -X POST https://staging.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,
 "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 staging.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. |
| created_at, started_at, finished_at | Unix seconds. The last two are null until they happen. |

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": "18 of 34 stills, 3 of 9 clips",
  "phases": {
    "plan":     {"status": "done",    "detail": "34 shots planned"},
    "voice":    {"status": "done",    "detail": "narration recorded", "started": "2026-09-08T10:18:02", "finished": "2026-09-08T10:21:40"},
    "broll":    {"status": "running", "detail": "18 of 34 stills, 3 of 9 clips", "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 | The detail line of the current phase. May be empty. |
| phases | All seven phases, in that order, each with a status of pending, queued, running or done, and a detail. 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"}.
A submitted video runs to the end; there is no cancel. If something is wrong with one, email hello@staging.headcast.ai and we will stop it and return the minutes.

### 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: headcast-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 (legacy packs), 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.

```
{"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. |
| 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, 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 3 videos queued or rendering. 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.
