Channel Farm video · ops
Developers

API reference

Everything the Channel Farm dashboard can do, over plain HTTPS: scripts, brand profiles, voices, visual styles, characters, and rendered videos. Base URL https://api.channel.farm/v1. An OpenAPI spec is served at /v1/openapi.json. Prefer driving Channel Farm from an AI agent? Use the MCP server instead. Same account, same tokens.

Authentication

Every request needs a personal access token in the Authorization header. Create one in your dashboard under Settings → Connect & API. The token starts with cf_live_ and is shown once, at creation.

Header
Authorization: Bearer cf_live_... 

A token has full access to your account. Keep it server-side, never in a browser or a public repo. Revoke it from the same page if it leaks. The same token also works for the MCP server.

Quickstart

From idea to rendered video in four calls. First, check what your account can do:

1. Account
curl https://api.channel.farm/v1/account \
  -H "Authorization: Bearer $CF_TOKEN" 
2. Generate a script (async)
curl -X POST https://api.channel.farm/v1/scripts \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"idea": "The hidden history of coffee", "target_length_minutes": 5}' 
3. Poll the script until ready is true
curl https://api.channel.farm/v1/scripts/<script_id> \
  -H "Authorization: Bearer $CF_TOKEN" 
4. Render it (async), then poll the video
curl -X POST https://api.channel.farm/v1/videos \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"brand_profile_id": "<id>", "script_id": "<script_id>", "pacing": "dynamic"}' 

You need at least one brand profile with a voice set before rendering. Create one with POST /brand-profiles or in the dashboard.

Errors

Errors are JSON with a stable code and a human-readable message. When subscribing or upgrading clears the error, the body also carries pricing_url.

Error shape
{
  "error": {
    "code": "credit_quota_exhausted",
    "message": "You've used all 750 credits in your Starter plan for this billing period. ...",
    "pricing_url": "https://channel.farm/pricing",
    "action_required": "subscribe_or_upgrade"
  }
} 
Status codes
400 invalid_argument A parameter is missing or malformed. Also content_blocked when an image safety check rejects a style prompt.
401 unauthorized Missing, invalid, revoked, or expired token.
402 payment Subscription or credits required: subscription_required, credit_quota_exhausted, max_length_exceeded, brand_profile_limit_reached, and similar. Includes pricing_url.
403 forbidden Not available on this account: feature_not_enabled, publishing_unavailable, account_blocked, and similar. Retrying does not help.
404 not_found The resource does not exist or belongs to another account: script_not_found, video_not_found, route_not_found, and similar.
409 conflict Valid request, wrong state: script_not_ready, brand_profile_needs_elevenlabs_voice (the profile has no voice that can narrate), character_not_approved, voice_in_use, and similar.
422 clone_rejected Google refused a cloned voice recording. Record it again.
429 rate_limited Too many requests, or a daily limit reached. Wait and retry.
502 upstream_failed A service we call failed: image_fetch_failed, preview_failed, create_failed, and similar. Safe to retry.
503 unavailable Switched off or busy for a moment: rendering_paused, voice_tags_unavailable, and similar. Retry later.
5xx server_error Something failed on our side. Safe to retry with backoff.

Credits & rate limits

Rendering spends credits, the same currency as the dashboard. A render costs about minutes × rate for its pacing, never less than the minimum: 25 credits for a 16:9 video and 8 for a 9:16 one (GET /account has the live figures):

Credit rates
relaxed 5 / min Calm pacing. A new image roughly every 12 to 22 seconds. Stretches credits the furthest.
dynamic 7 / min A new image roughly every 8 to 12 seconds.
rapid 9 / min High energy. A new image roughly every 3 to 6 seconds.
hyper 12 / min The fastest cut speed. The most images per video.

Animated scenes cost extra on top of that. Each animated scene is charged by its length, so turning animation on can multiply the price of a long video. Send your settings to POST /scripts/validate to see the real figure before you spend anything.

Check your balance with GET /credits and preview a script's cost with POST /scripts/validate. Script generation is capped at 30 per hour and renders at 10 per hour. Reads are not metered. Rendering requires an active paid plan. Without one, reads and profile management still work.

Polling

Script generation and rendering are asynchronous. The create call returns immediately with an id. For videos, POST /videos also returns estimated_build_seconds and recommended_first_poll_seconds. Wait that long before the first poll, then follow next_poll_after_seconds from each GET /videos/:id response. Renders typically take 5 to 40 minutes depending on length and load.

Estimates use recent comparable generations and exclude time spent editing. With advanced editing enabled, the estimate ends at ready_to_edit; otherwise it ends when the video is rendered. Estimate fields may be null when timing data is unavailable; the polling recommendation still applies.

GET /account

Plan, credits, quotas, and limits.

Response (trimmed)
{
  "subscription_tier": "starter",
  "subscription_status": "active",
  "can_render_video": true,
  "plan": { "max_video_length_min": 15, "videos_per_month": 6 },
  "credits": {
    "allowance": 750,
    "used": 210,
    "remaining": 540,
    "rollover": 0,
    "period_end": "2026-09-01T00:00:00+00:00",
    "bonus": { "balance": 0, "soonest_expiry": null, "purchased": 0, "gifted": 0 },
    "rates": {
      "per_minute": { "relaxed": 5, "dynamic": 7, "rapid": 9, "hyper": 12 },
      "minimum_per_video": 25,
      "minimum_per_short_video": 8,
      "note": "A render costs about minutes × rate for its pacing, never less than the minimum..."
    }
  },
  "script_quota": { "used": 3, "limit": 60, "remaining": 57, "unlimited": false },
  "input_limits": { "idea_min_chars": 5, "idea_max_chars": 2000, "script_max_video_length_min": 15 }
} 
GET /credits

Credit balance for this billing period.

The credits block on its own, for cheap balance checks between renders. allowance includes any rollover. bonus is gifted or topped-up credits, spent after the plan allowance. Null allowance means no active billing period.

Response
{
  "subscription_tier": "starter",
  "can_render_video": true,
  "allowance": 750,
  "used": 210,
  "remaining": 540,
  "rollover": 0,
  "period_end": "2026-09-01T00:00:00+00:00",
  "bonus": { "balance": 100, "soonest_expiry": "2026-08-20T00:00:00+00:00", "purchased": 0, "gifted": 100 },
  "rates": {
    "per_minute": { "relaxed": 5, "dynamic": 7, "rapid": 9, "hyper": 12 },
    "minimum_per_video": 25,
    "minimum_per_short_video": 8,
    "note": "A render costs about minutes × rate for its pacing, never less than the minimum..."
  }
} 
GET /brand-profiles

List brand profiles.

A brand profile is the reusable identity videos are rendered with: voice, visual style, captions, and optionally a cast of recurring characters. render_ready is true only when the profile has a voice that can narrate: an ElevenLabs voice, a Google voice, or one of your own. voice_tags says which delivery marks that voice performs (see GET /brand-profiles/:id/voice-tags).

Response
{
  "brand_profiles": [
    {
      "id": "9a1e...",
      "name": "Sleepless Histories",
      "is_favorite": true,
      "language": "en",
      "render_ready": true,
      "voice": { "id": "41b0...", "name": "Archer", "provider": "elevenlabs", "custom": false },
      "voice_tags": "eleven-v4",
      "visual_style": { "id": "77aa...", "name": "Storybook Ink" },
      "character": {
        "id": "c3f2...",
        "name": "Professor Owl",
        "status": "approved",
        "style_matches_profile": true
      }
    }
  ]
} 
POST /brand-profiles

Create a brand profile.

Body
name required string Profile name, 1 to 80 characters.
description string Channel or niche description, up to 500 characters.
voice_id uuid A voice from GET /voices. Required before the profile can render.
visual_style_id uuid A style from GET /visual-styles or POST /visual-styles.
language string The language its videos are written and narrated in, as a code like en or es. Default en.
text_enabled boolean Word-by-word captions. Default true.
caption_* mixed caption_font, caption_size, caption_color, caption_highlight_color, caption_shadow, caption_words_per_line, caption_uppercase, caption_position, caption_x_pct and caption_y_pct. See PATCH below.
short_captions object Captions for 9:16 videos only. See PATCH below.
voice_tuning object ElevenLabs voice dials. See PATCH below.
voice_style string Google voices only: how the narrator delivers every line. See PATCH below.

Counts against your plan's brand-profile limit. A new account gets its first profile free. Returns 201 with the new brand_profile_id.

PATCH /brand-profiles/:id

Update a brand profile.

Body (only provided fields change)
name string New name, 1 to 80 characters.
description string New description, up to 500 characters.
voice_id uuid A voice from GET /voices. A new voice starts from the default dials; a Google delivery style is kept.
visual_style_id uuid A style from GET /visual-styles.
language string The language its videos are written and narrated in. Videos already being made keep theirs.
character_id uuid | "none" Make an approved character from GET /characters the lead, or "none" to take the lead out. Attaching also switches the profile to the character's own visual style. POST /brand-profiles/:id/characters sets the whole cast.
text_enabled boolean Word-by-word captions on or off.
caption_font string One of the installed caption fonts, for example Inter, Montserrat, Bebas Neue, Anton or Poppins.
caption_size number 25 to 120. Captions that are off need text_enabled: true with it.
caption_color string A hex code like "#ffffff", or white, lime, yellow, orange, red or blue.
caption_highlight_color string The colour of the word being spoken. Same formats.
caption_shadow string The outline behind the captions: none, soft, medium, hard or glow.
caption_words_per_line number 1 to 10 words on screen at once.
caption_uppercase boolean Captions in capitals.
caption_position string Where captions sit: top, middle or bottom, then left, center or right (e.g. top-center). bottom-center is the normal spot and resets the position. Kept on screen in 16:9 and 9:16 videos.
caption_x_pct number An exact spot instead: the caption box’s centre, 0 (left) to 100 (right). Pulled in as far as keeps the text on screen.
caption_y_pct number The caption box’s bottom edge, 0 (top) to 100 (bottom). The normal spot is 92.
short_captions object | "inherit" Captions for 9:16 videos only: any of enabled, font, size (44 to 213), color, highlight_color, shadow, words_per_line (1 to 6) and uppercase. Send only what should differ. "inherit" drops the override.
voice_tuning object | "reset" ElevenLabs dials: stability, similarity_boost and style (0 to 1), and speed (0.7 to 1.2). Only the dials you name change. "reset" goes back to the defaults.
voice_style string Google voices only: how the narrator delivers every line, up to 300 characters. An empty string removes it.

An ElevenLabs voice at speed 1 renders on Eleven v4 Turbo, which uses stability and similarity_boost and performs voice tags. Any other speed moves it to multilingual-v2, which uses all four dials but would read a tag out loud, so it takes pauses only. Hear a setting before saving it with POST /voices/preview.

Request
curl -X PATCH https://api.channel.farm/v1/brand-profiles/<id> \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"caption_size": 48, "voice_tuning": {"stability": 0.4}}' 
GET /brand-profiles/:id

Every setting of one brand profile.

Every setting of one profile, in the same terms PATCH takes: the voice and the model it renders on, its tuning or delivery style, which voice tags it performs, the visual style, the captions for 16:9 and 9:16, and the cast. Read it before you change anything.

Response (trimmed)
{
  "brand_profile_id": "9a1e...",
  "name": "Sleepless Histories",
  "language": "en",
  "render_ready": true,
  "voice": { "id": "41b0...", "name": "Archer", "provider": "elevenlabs", "custom": false },
  "voice_tuning": { "stability": 0.5, "similarity_boost": 0.75 },
  "voice_style": null,
  "voice_model": "Eleven v4 Turbo (stability and similarity_boost apply; voice tags work)",
  "voice_tags": "eleven-v4",
  "visual_style": { "id": "77aa...", "name": "Storybook Ink", "is_yours": false },
  "captions": { "enabled": true, "font": "Inter", "size": 35, "color": "#ffffff", "highlight_color": "#00ff00", "shadow": "soft", "words_per_line": 4, "uppercase": false,
    "position": { "x_pct": 50, "y_pct": 23.9, "max_width_pct": 96, "quick_position": "top-center" } },
  "short_captions": null,
  "cast": [
    { "id": "c3f2...", "name": "Professor Owl", "enabled": true, "lead": true, "mention": "@professor-owl", "style_matches_profile": true }
  ]
} 
DELETE /brand-profiles/:id

Delete a brand profile for good.

Permanent. It also deletes the characters that were created on this profile and the scripts written for it, and the response counts them. Videos already made are kept. Refused with a 409 while one of its videos is still being made.

POST /brand-profiles/:id/characters

Set a profile’s cast of characters.

Body
characters required array The whole list, in order: [{ "id": "<character id>", "enabled": true }]. enabled defaults to true, and at most 4 can be on. [] means no cast.

The whole cast of recurring characters, replacing what was there. The first one switched on is the lead. Every new video from the profile uses the switched-on cast, and character_ids on POST /videos can pick a different cast for one video. Each must be your own approved character. One that is switched on must be drawn in the profile’s visual style; one switched off can stay on the list.

GET /brand-profiles/:id/voice-tags

The voice tags a profile’s voice performs.

Which delivery marks this profile’s voice performs, with every one of them and a hint. Write them into the script you render. They are performed, never spoken, and never shown in the captions.

Sets
eleven-v4 [tags] ElevenLabs voices: tags in square brackets, like [whispers] or [pause].
google <cues> Google voices: cues in angle brackets, like <breath> or <short pause>.
pauses <break /> An ElevenLabs voice with a custom speed: pauses only, like <break time="1s" />.

A set of null means this voice is offered no marks right now: write plain narration. POST /scripts/voice-tags writes them in for you.

GET /characters

List recurring characters.

A recurring character looks the same in every video it appears in. Draw one with POST /characters, save it with POST /characters/:id/approve, and set which characters a profile uses with POST /brand-profiles/:id/characters. This list shows each character and the profile casts it is in.

Response
{
  "characters": [
    {
      "id": "c3f2...",
      "name": "Professor Owl",
      "status": "approved",
      "attachable": true,
      "mention": "@professor-owl",
      "preview_image_url": "https://...",
      "visual_style_id": "77aa...",
      "visual_style_name": "Storybook Ink",
      "created_for_brand_profile_id": "b1d0...",
      "in_casts": [{ "brand_profile_id": "b1d0...", "brand_profile_name": "History Owl", "enabled": true, "lead": true }],
      "created_at": "2026-07-25T18:12:00+00:00"
    }
  ]
} 
GET /voices

Browse narration voices.

Query
provider string elevenlabs or google. Both if omitted.
language string Only voices for this language, as a code like en or de.
gender string male, female or neutral.
accent string Substring match, for example american or british.
niche string Voices suited to this kind of channel.
search string Name or description search.
yours_only boolean Only the voices you designed or cloned.
limit integer 1 to 50. Default 12.
offset integer Skip this many. Take next_offset from the previous page.

About 1,200 ElevenLabs voices, over 2,000 Google voices in many languages, and your own voices (is_yours). Featured voices come first. A voice with a null preview_url gets one from POST /voices/preview. Use the id as voice_id.

POST /voices/preview

Hear a voice.

Body
voice_id uuid A voice from GET /voices. Or send brand_profile_id.
brand_profile_id uuid Hear this profile’s voice with its saved settings.
voice_tuning object ElevenLabs only: hear these dials before saving them. stability, similarity_boost and style 0 to 1, speed 0.7 to 1.2.
voice_style string Google only: hear this delivery style before saving it. Up to 300 characters.

Returns an audio_url: the same sample line, in the voice’s own language, on the model your videos render with. With no dials or style to try, a library voice returns its standard sample. Free. Limited to a few a minute and a daily number.

POST /voices

Design or clone a voice of your own.

Body
mode required string design to describe a voice, or clone to use your own recordings.
name required string The voice’s name, 1 to 40 characters.
description string design only: 20 to 600 characters on how it sounds (age, pitch, pace, accent, warmth), not what it says.
gender string design only: male, female or neutral.
language string design only: the language it speaks best, as a code. Default en.
source_audio_base64 string clone only: a WAV of 10 to 30 seconds of you speaking naturally, as base64, up to 4 MB.
consent_audio_base64 string clone only: a WAV of 3 to 20 seconds of you reading the consent sentence below exactly, as base64, up to 4 MB.
attest_own_voice boolean clone only: must be true. Cloning is for the account holder’s own voice only.

A Google voice only your account can use. Starter plan and up. Each voice costs 15 credits, taken when it starts and refunded if it fails. Each plan includes a number of voices: 1 on Starter, 2 on Developmental, 3 on Professional. The response has a sample_url to listen to.

Consent sentence (read it word for word)
I am the owner of this voice and I consent to Google using this voice to create a synthetic voice model. 
DELETE /voices/:id

Delete one of your own voices.

Deletes one of your own voices for good, with its recordings. Refused with a 409 while a brand profile still uses it: change the voice on those profiles first. The credits it cost are not returned. Videos already made keep their narration.

GET /visual-styles

List visual styles.

The stock style catalog plus your own custom styles (is_yours). Each has a sample image_url showing the look. Filter with ?category=.

POST /visual-styles

Create a custom visual style.

Body
name required string Style name, 1 to 80 characters.
style_prompt required string The art direction appended to every image prompt, 20 to 2000 characters. Describe medium, technique, palette, lighting, and mood. Never a subject.
description string One line shown in the picker, up to 300 characters.
image_rules string Hard constraints for every image, up to 2000 characters. Do not name an aspect ratio: one style serves 16:9 and 9:16.
figure_law string How people are drawn in this style, in a sentence or two. Up to 400 characters.
reference_images array 1 to 3 example pictures of the look, each {"url": "https://..."} or {"base64": "..."}. JPG, PNG or WebP, under 5 MB. They steer every scene drawn in this style.
image_url string Optional card art. Omit it and your first example picture is the card; with no pictures either, a card is rendered from style_prompt. Or send image_base64 with image_mime_type.

Custom styles are private to your account, and a paid feature. Limit 20. The pictures steer the look of every scene: the example pictures when you send them, and otherwise the card art. Three example scenes are drawn in the background after it is saved.

PATCH /visual-styles/:id

Change one of your own styles.

Body (only provided fields change)
name string New name, 1 to 80 characters.
description string Up to 300 characters.
style_prompt string 20 to 2000 characters.
image_rules string An empty string clears it.
figure_law string An empty string clears it.

Only your own styles. When the look changes on a style without example pictures, its card is drawn again and new example scenes follow. Videos already made keep their look. Example pictures cannot be changed: make a new style for a new set.

DELETE /visual-styles/:id

Delete one of your own styles.

Deletes one of your own styles for good. A style that has already made videos cannot be deleted (409). Brand profiles still using it are named in the response and need another style before they can make new videos.

POST /scripts

Generate a script from an idea.

Body
idea required string The topic or idea, 5 to 2000 characters.
brand_profile_id uuid The profile the video is for. Its language and voice shape the script. Recommended.
target_length_minutes number 0.5 to 60, never past your plan’s max length. Default 8.
style string first-person, storytelling, educational, motivational, or tutorial. Default storytelling.
language string Write in this language instead of the profile’s. Send the same one to POST /videos.

Returns 202 with a script_id. Poll GET /scripts/:id until ready is true. The defaults match the create page’s Write button. Counts against your script quota. Capped at 30 per hour.

GET /scripts/:id

Fetch a generated script.

The script comes back with any voice tags the writer added. A script that ran past your plan’s length is cut at a whole sentence, the way the create page cuts it, and rendering this script_id renders exactly that text.

Response
{
  "script_id": "5b7c...",
  "status": "completed",
  "ready": true,
  "script": "Every morning, two billion people...",
  "title": "The Hidden History of Coffee",
  "topic": "The hidden history of coffee",
  "language": "en",
  "estimated_seconds": 482,
  "has_voice_tags": false
} 
POST /scripts/validate

Estimate length and credit cost.

Estimate a script's spoken length, whether it fits your plan, and what it would cost to render at each pacing. Free to call. It measures the words that are spoken: voice tags and cues are not counted. Send language or the brand_profile_id you will render with, because languages are spoken at different speeds.

You can also send any of the settings from POST /videos. You then get back estimated_credits_for_settings: the price of that exact video, animation included. Do this before you animate anything.

With brand_profile_id, it also checks the script’s tags against that profile’s voice. Tags written for a different voice come back in foreign_voice_tags; POST /videos refuses the script until they are fixed. Send fix_foreign_tags here to see and price the fixed script first.

Response
{
  "language": "en",
  "estimated_seconds": 292,
  "estimated_minutes": 4.9,
  "plan_max_minutes": 15,
  "fits_plan": true,
  "estimated_credits": { "relaxed": 25, "dynamic": 34, "rapid": 44, "hyper": 59 },
  "estimated_credits_for_settings": 96,
  "delivery_marks": { "tags": 3, "cues": 0 }
} 
GET /scripts

List your saved scripts.

Query
brand_profile_id uuid Only scripts written for this profile.
limit integer 1 to 50. Default 10.
offset integer Skip this many. Take next_offset from the previous page.

Your saved scripts, newest first. Any one with ready: true renders with script_id on POST /videos.

DELETE /scripts/:id

Delete a saved script.

Deletes a saved script. Videos already made from it are unaffected, and it still counts toward this month’s script allowance. A script that is still being written cannot be deleted yet (409).

POST /scripts/voice-tags

Have AI add voice tags or cues to a script.

Body
script_text required string The script to mark up.
brand_profile_id required uuid The profile the video will be made with. Its voice decides which marks.

The create page’s “Add with AI”. An AI adds [tags] for an ElevenLabs voice or <cues> for a Google voice, and never changes a word. Free, within a daily number of runs. Show the result to the person, then send it as script_text to POST /videos.

POST /visual-directions

Draft a visual direction.

Body
script_text required string The script the video will be made from, at least 40 characters.
brand_profile_id uuid The profile it will be made with. Its style and cast shape the draft.
character_ids uuid[] The cast of this video, if not the profile’s. Needs brand_profile_id.
redo boolean A fresh draft of words drafted before. Otherwise the same words return the same draft for a week.

The create page’s “Ask AI for a draft”: the world the scenes are drawn in, written from the script. Free, needs a paid plan, and has an hourly limit. It takes 20 to 60 seconds, so poll GET /visual-directions/:id. Edit it with the person, then send it as visual_direction on POST /videos.

GET /visual-directions/:id

Poll a visual direction draft.

Response (ready)
{
  "visual_direction_id": "0c1d...",
  "status": "completed",
  "ready": true,
  "direction": "A rain-soaked harbour town in the 1920s...",
  "error": null
} 
POST /videos

Render a video.

Body
brand_profile_id required uuid A render-ready profile from GET /brand-profiles.
script_id uuid A completed script. Mutually exclusive with script_text.
script_text string Your own script. Mutually exclusive with script_id. It may carry the delivery marks the profile’s voice performs (GET /brand-profiles/:id/voice-tags). They are performed, never spoken or captioned.
title string Working title, up to 120 characters. Defaults to the brand profile name. We write the real title after the render either way.
fix_foreign_tags string convert or remove. Tags the profile’s voice cannot use (written for a different voice) stop the render with 400 voice_tags_wrong_voice. convert swaps each for the same sound in this voice’s marks and removes the rest; remove removes them all.
pacing string relaxed, dynamic, rapid or hyper. Sets how often the image changes and scales the credit cost. Default relaxed.
pace_mode string uniform for one pace throughout, or hook for a faster opening. Default uniform.
hook_pace string How fast the opening cuts. Must be faster than pacing. Only with pace_mode hook. Default hyper.
hook_seconds integer How long the faster opening lasts, 5 to 90. Only with pace_mode hook. Default 30.
animate string off, first, percent or all. Turns stills into moving clips. Needs a Starter plan or above and costs extra credits. Default off.
animate_first_scenes integer How many opening scenes to animate, 1 to 12. Only with animate first. Default 3.
animate_percent integer What share of scenes to animate, 5 to 100. They are spread evenly. Only with animate percent. Default 30.
transition string random, pushRight, fade or none. How one scene becomes the next. Default random.
motion string random, zoomIn, zoomOut, panLeft, panRight or none. The slow move each still gets. Default zoomIn. Animated scenes ignore it.
motion_speed string slow, medium or fast. Default slow.
language string Narrate and caption this one video in another language. Overrides the brand profile.
format string long for 16:9, the default, or short for vertical 9:16. A shape, not a length: both follow the same plan length cap.
character_ids uuid[] The recurring characters in this video, lead first, up to 4. Leave it out to use the profile’s cast. [] means none. Each must be your own approved character drawn in the profile’s style.
visual_direction string The world the scenes are drawn in, up to 1500 characters. Write @name to place a cast member. Leave it out for the normal, more varied scenes.
visual_direction_id uuid A finished draft from POST /visual-directions. Its text is used when visual_direction is not sent.
edit_before_render boolean Stop at ready_to_edit instead of finishing, so you can change scenes and narration first. Default false.
Response (202)
{
  "video_id": "e8d4...",
  "status": "processing",
  "title": "Coffee history",
  "pacing": "dynamic",
  "language": "en",
  "settings": {
    "pacing": "dynamic",
    "animate": "first",
    "animate_first_scenes": 3,
    "transition": "fade",
    "motion": "zoomIn"
  },
  "edit_before_render": false,
  "estimated_credit_cost": 95,
  "estimated_build_seconds": 1348,
  "estimated_build_minutes": 22.5,
  "recommended_first_poll_seconds": 1348,
  "recommended_poll_interval_seconds": 30
} 

This is the one paid action: it reserves the credit cost up front. Requires an active paid plan or bonus credits. Capped at 10 renders per hour. A script longer than your plan allows is refused with a 402; a generated script is first cut to fit at a whole sentence, as the create page does.

Everything except brand_profile_id and the script is optional. Send nothing else and you get the same video this endpoint always made. The settings field in the response is what the render actually started with, defaults filled in.

With edit_before_render set, the video stops at ready_to_edit and waits. Nothing finishes it until you export it, so only set it if you plan to edit.

GET /videos

List recent videos.

Query
status string draft, processing, ready_to_edit, completed or error.
archived string exclude (the default, your library), only (the Archived tab), or include.
format string long for 16:9 or short for 9:16.
search string Words in the title.
limit integer 1 to 50. Default 10.
offset integer Skip this many. Take next_offset from the previous page.

Most recent first.

GET /videos/:id

Poll a video render.

Response (completed)
{
  "video_id": "e8d4...",
  "status": "completed",
  "ready": true,
  "progress_percent": 100,
  "next_poll_after_seconds": null,
  "video_url": "https://storage.googleapis.com/.../final.mp4",
  "youtube_url": null,
  "thumbnail_url": "https://...",
  "duration_seconds": 294,
  "youtube_metadata": {
    "title": "The Hidden History of Coffee",
    "description": "...",
    "tags": ["coffee", "history"]
  },
  "error": null
} 
GET /videos/:id/scenes

List a video’s scenes.

Every scene of a finished video, in order: the prompt it was drawn from, a link to the image, the narration spoken over it, how long it lasts, and whether it has been animated.

Scenes are numbered from 0. That number is how you refer to a scene everywhere else, so you do not have to keep track of ids. A motion of null means the scene follows the video setting, which is not the same as "none".

Response
{
  "video_id": "e8d4...",
  "status": "ready_to_edit",
  "editable": true,
  "scene_count": 34,
  "scenes": [
    {
      "scene": 0,
      "image_id": "9f2a...",
      "prompt": "A hand pouring coffee...",
      "narration": "Every morning, two billion people...",
      "image_url": "https://storage.googleapis.com/.../image_0.png",
      "seconds": 14.2,
      "motion": null,
      "transition_after": null,
      "animated": false,
      "ready": true
    }
  ]
} 
GET /videos/:id/narration

List a video’s narration lines.

The narration, line by line. Only videos with segmented narration have separate lines. An older video returns segmented: false and its narration cannot be changed.

Line numbers have gaps where lines were deleted. Read seq from this response rather than counting.

Each line says which voice tags its voice reads in tag_family: elevenlabs ([tags]), google (<cues>) or none. A line that carries tags also has spoken_text: the line as its voice is given it.

POST /videos/:id/open

Open a finished video for editing.

Open a finished video so it can be changed. Nothing can be edited until you do.

The video moves to ready_to_edit and stays there. The version people can watch is untouched until you export, and nothing else will finish it. Safe to call twice. A video created with edit_before_render is already open.

POST /videos/:id/export

Render your edits.

Render your edits and make them the version people see. This is the only thing that finishes a video waiting at ready_to_edit. Poll GET /videos/:id until it is completed.

A video can only be exported a limited number of times, and the response says how many are left. A 409 with voiceover_master_stale means a narration edit is still settling: wait a few seconds and call it again.

Editing scenes

Every endpoint below needs the video open for editing, and names a scene the same way: send scene (its number from GET /videos/:id/scenes, counting from 0) or image_id. Nothing you change here is visible until you export.

POST /videos/:id/scenes/regenerate

Draw a scene again.

Body
scene integer The scene number. Or send image_id.
image_id uuid The scene’s image id, instead of scene.
mode string rewrite (the default) draws it again from a prompt. edit changes the current picture from a short instruction, where the video’s style allows it.
prompt string rewrite: what the scene should show. Describe the subject and action, not the art style: the video’s style is added on top. Leave out to redraw the same prompt. edit: the change to make, like “make the sky stormy”.
quote_only boolean Return what the next drawing costs and spend nothing.

On a black screen this fills it with a new picture from prompt. Write @name in a prompt to put one of the video’s characters in.

Each video gets a number of free scene drawings, 10 by default, shared with POST /videos/:id/scenes/add, and does not get more. After those, a drawing costs credits and goes ahead rather than being refused; the response says which happened. Going back to an earlier picture is free, so check the versions first. The new picture takes a minute. Poll GET /videos/:id/scenes until that scene is ready.

GET /videos/:id/scenes/versions

A scene’s earlier pictures.

Every picture drawn for one scene, oldest first, with the prompt each came from and which one is showing. Going back to one is free, so check here before spending another redraw.

POST /videos/:id/scenes/versions

Go back to an earlier picture.

Show an earlier picture instead of the current one. Send the file_path from the versions list. Free, instant, and reversible.

POST /videos/:id/scenes/split

Cut a scene into two.

Cut one scene into two showing the same picture, so a long stretch of narration can have two pictures. The video does not get longer: the scene’s time is divided between the halves. Send at_seconds to choose where, measured from the scene’s own start; it defaults to halfway.

Both halves show the same picture until you regenerate one, which gives it a picture of its own automatically. Scene numbers after the split shift up by one.

POST /videos/:id/scenes/remove

Take a scene out.

Take a scene out. Its time goes to the scene before it, so the video stays exactly as long and the narration is untouched: the previous picture just stays on screen longer. Scene numbers after it shift down by one.

To hide a scene without giving its time away, black it out instead. To make the video shorter, delete the narration.

POST /videos/:id/scenes/blank

Black out a scene.

Replace a scene’s picture with a black screen for exactly as long as that scene lasted. Nothing else moves and the narration keeps playing. Fill it again later with a new drawing or your own image.

PATCH /videos/:id/scenes

One scene’s motion, transition, fades.

Body
scene integer The scene number. Or send image_id.
motion string inherit, zoomIn, zoomOut, panLeft, panRight, panUp, panDown or none. "inherit" follows the video; "none" holds still. Animated scenes ignore it.
motion_speed string slow, medium or fast. Default slow.
transition string How this scene becomes the next one. "inherit" follows the video. Meaningless on the last scene.
fade_in_seconds number Fade from black at the start. 0 turns it off. Capped at 3s and at half the scene.
fade_out_seconds number Fade to black at the end.

Setting a transition clears any fade at that cut, because a fade would otherwise outrank it and the transition would never play.

PATCH /videos/:id/style

The whole video’s cuts and motion.

Body
transition string How every scene becomes the next: random, alternate, fade, a push or slide direction, or none.
clear_fades boolean With a new transition: also remove the scene fades at cuts, which would otherwise play instead of it. The very first and last fades stay.
motion string random, zoomIn, zoomOut, panLeft, panRight or none. The slow move every still scene gets.
motion_speed string slow, medium or fast. Sent alone, it changes only the speed.

Works like the editor’s pickers. A new transition applies to every cut and clears the per-cut ones you had pinned. A new motion applies to every still scene, clears the per-scene ones, and keeps the current speed unless you send one.

POST /videos/:id/scenes/animate

Turn a still into a moving clip.

Turn one scene’s still picture into a short moving clip. This spends credits. They are charged when it starts and are not refunded if you change your mind, and the amount scales with how long the scene runs.

Send quote_only: true first to see the price without spending anything. Needs a Starter plan or above. It takes a minute or two; poll the scenes list until that scene shows animated: true.

POST /videos/:id/scenes/unanimate

Back to the still picture.

Put an animated scene back to its still picture. The credits already spent are not refunded, so this undoes the look, not the cost.

POST /videos/:id/scenes/add

Draw a new picture into the video.

Body
prompt string What the new scene should show. Describe the subject, the action and the setting, not the art style: the video’s own style is added on top. Do not ask for text or lettering. Required unless quote_only is true.
after_scene integer The scene number to put it after. Defaults to the end of the video. That scene is halved to make room, so it has to run at least 2 seconds.
quote_only boolean Return what the drawing would cost, and spend nothing.

The scene before it is cut in half, so the video does not get longer and the narration is untouched. Use it when a stretch of narration needs a second picture, not to make the video say more. Scene numbers after the new one shift up by one.

Each video gets 10 free scene drawings, shared with POST /videos/:id/scenes/regenerate. After those, every drawing costs credits. The response says which happened, in credits_charged and free_drawings_remaining. The picture takes a minute; poll GET /videos/:id/scenes until it is ready.

POST /videos/:id/scenes/upload

Put your own image in a scene.

Body
scene integer The scene number. Or send image_id.
image_id uuid The scene’s image id, instead of scene.
image_url string A public https URL we can download the image from. Send this or image_base64, not both.
image_base64 string The image bytes as base64. A "data:" prefix is accepted and stripped. Send this or image_url, not both.
name string Label for the upload in your media list. Up to 80 characters.

Put your own photo or artwork into a scene, or into a black screen, for the times no prompt would produce it. JPG, PNG or WebP, at least 320 by 180, up to 10 MB. Free, and the picture it replaces is kept, so POST /videos/:id/scenes/versions can put it back.

Capped at 30 uploads an hour across your account, and 200 per video.

POST /videos/:id/scenes/cut

Move the cut between two scenes.

Body
scene integer The scene number. Or send image_id.
image_id uuid The scene’s image id, instead of scene.
delta_seconds required number Seconds to move the cut by, up to 120. Positive holds this scene longer and shortens the next one. Negative does the reverse.

The time moves between the two scenes, so the video’s length and its narration never change. Use it when a picture arrives too early or lingers.

If the amount you asked for would push a neighbour under its minimum length, it is trimmed to fit. The response then has clamped: true and moved_seconds is what actually happened.

POST /videos/:id/scenes/reorder

Move a scene somewhere else.

Body
scene integer The scene number to move. Or send image_id.
image_id uuid The scene’s image id, instead of scene.
to_scene required integer The position to move it to, counting from 0.

Works like dragging a scene in the editor. The cuts stay where they are on the narration and black screens stay put, so the picture you moved fills its new slot’s length and plays over different words. Re-read GET /videos/:id/scenes afterwards and check each picture still matches what is being said.

Editing the narration

These four are the only endpoints that change how long a video is. Everything under Editing scenes moves time between scenes and leaves the total alone. Like scene editing, they need the video open first: see POST /videos/:id/open. Only videos with segmented narration have separate lines: check GET /videos/:id/narration first, which also reports the edit budget. Rewriting or adding a line uses it: the first 3 of a video are free and each one after that costs a credit. Deleting or muting a line is free and does not use it. Name a line by its seq or its segment_id. Numbers keep their gaps after a deletion, so read them rather than counting. Nothing here reaches the version people watch until you export.

POST /videos/:id/narration/rewrite

Say a line again, or differently.

Body
line integer The line’s seq from GET /videos/:id/narration. Or send segment_id.
segment_id uuid The line’s id, instead of line.
text string New words for this line, under 400 characters. It may carry the tags this line’s voice reads ([tags] for an ElevenLabs line, <cues> for a Google one); the captions keep the plain words. Pauses (<break/>) cannot go in a line. Leave it out to say the same words again with a fresh take.

This is the only way to change what the video says. If the new take is a different length, the video’s length changes with it and the pictures shift to match, so keep new words close to the original length. It takes a few seconds. Poll GET /videos/:id/narration until the line is ready, then re-read the scenes before judging the timing.

POST /videos/:id/narration/insert

Add a spoken line.

Body
text required string What the new line says. Under 400 characters. Plain words only: add voice tags afterwards with POST /videos/:id/narration/rewrite.
after_line integer The seq of the line to put this one after. 0 puts it before the first line, which is the default.

The video gets longer by however long the line takes to say. No scene is added: the picture covering that moment simply stretches over the new words, so follow with POST /videos/:id/scenes/add if they deserve their own picture. Line numbers after it shift up by one.

POST /videos/:id/narration/delete

Cut a spoken line out.

Body
line integer The line’s seq from GET /videos/:id/narration. Or send segment_id.
segment_id uuid The line’s id, instead of line.

The video gets shorter by exactly that line and the pictures close up over the gap. This is how you cut something the video should not say. Removing a scene does not shorten anything.

It is free and does not use up a narration edit. The line numbers left behind keep their gaps.

POST /videos/:id/narration/mute

Silence a line, keep its pause.

Body
line integer The line’s seq from GET /videos/:id/narration. Or send segment_id.
segment_id uuid The line’s id, instead of line.
muted boolean true silences the line, which is the default. false makes it speak again.

The words stop, the pause stays, and nothing else moves. Use it to drop a sentence while leaving every picture exactly where it is. To take the time back as well, delete the line instead.

Publishing

What a finished video is published as: its title, description, tags and thumbnail. These do not need the video open for editing. Each field can be regenerated 3 times per video and that count never resets, so read GET /videos/:id/versions first. Putting an earlier one back is free and spends no regeneration.

GET /videos/:id/metadata

Title, description, tags, thumbnail.

GET /videos/:id says whether a video is done. This says whether it is any good: the title, description, tags and chapters it will publish as, the thumbnail and the prompt behind it, where it is on YouTube, and how many regenerations of each are left.

Response (trimmed)
{
  "video_id": "e8d4...",
  "status": "completed",
  "title": "The Hidden History of Coffee",
  "description": "...",
  "tags": ["coffee", "history"],
  "thumbnail_url": "https://...",
  "thumbnail_prompt": "THE PRESENTER holds up a steaming coffee cup...",
  "thumbnail_text": "NOBODY TOLD YOU",
  "chapters": [{ "title": "Intro", "start_seconds": 0 }],
  "youtube_url": "https://www.youtube.com/watch?v=...",
  "archived": false,
  "marked_published": true,
  "regenerations_left": { "title": 2, "description": 3, "tags": 3, "thumbnail": 1 }
} 
POST /videos/:id/metadata

Write the title, description or tags again.

Body
field required string title, description or tags. For the thumbnail use POST /videos/:id/thumbnail.

Free, but capped at 3 regenerations of that field per video, for ever. It takes a few seconds. Poll GET /videos/:id/metadata until the field changes. The old value is kept, so a regeneration is always reversible.

POST /videos/:id/thumbnail

Draw the thumbnail again.

Body
prompt string The picture, in one sentence: who is in it and the one object that carries the idea. Write THE PRESENTER for the recurring character of the video. No art style and no lettering here: use text for that. Keeps the current prompt if omitted.
text string The label hand-lettered across the thumbnail. Two to seven words works best. An empty string goes back to letting us choose. Omit to keep what is set.

The thumbnail is drawn in the video’s own style, from its first scenes, with its recurring character as the constant. Anything you leave out keeps what the video already has. Free, but capped at 3 thumbnail regenerations per video, for ever. Vertical 9:16 videos keep their first scene and are refused. It takes about a minute. Poll GET /videos/:id/metadata until thumbnail_url changes.

GET /videos/:id/versions

Earlier titles, tags and thumbnails.

Query
field string title, description, tags or thumbnail. All four if omitted.

Every title, description, tag set and thumbnail this video has had, oldest first, with is_current marking the one showing now. Always check here before spending another regeneration.

POST /videos/:id/versions

Put an earlier one back.

Body
version_id required uuid A version_id from GET /videos/:id/versions.

Free, instant, and it spends no regeneration. The value it replaces is kept too, so this is reversible in both directions.

PATCH /videos/:id

Rename a video.

Body
name string Up to 120 characters. An empty string clears it.

Your own label for the video, the name the library and GET /videos list it under. It is never sent to YouTube: the YouTube title, description and uploads stay as they are. A title sent to POST /videos is kept as this name.

POST /videos/:id/archive

Hide a video, or bring it back.

Body
archived boolean true hides it, which is the default. false brings it back into the library.

Reversible, and it destroys nothing: the video, its file and its history all survive. This is the one to reach for whenever a video just needs to be out of the way.

POST /videos/:id/published

Mark a video as published.

Body
published boolean true marks it published, which is the default. false unmarks it.

For a video you posted somewhere yourself: it only marks the video in your library. It posts nothing anywhere. To put a video on YouTube, use POST /youtube/posts.

PATCH /videos/:id/captions

One video’s captions.

Body (only provided fields change)
enabled boolean Captions on or off for this video.
font string One of the installed caption fonts, for example Inter, Montserrat, Bebas Neue, Anton or Poppins.
size number 25 to 120 for a 16:9 video. 44 to 213 for a 9:16 video, which is sized on a 1920-tall frame.
color string A hex code like "#ffffff", or white, lime, yellow, orange, red or blue.
highlight_color string The colour of the word being spoken. Same formats.
shadow string none, soft, medium, hard or glow.
words_per_line number 1 to 10.
uppercase boolean Captions in capitals.
position string Where the captions sit: top, middle or bottom, then left, center or right. bottom-center is the normal spot. Kept on screen in this video’s frame.
x_pct number An exact spot instead: the caption box’s centre, 0 (left) to 100 (right).
y_pct number The caption box’s bottom edge, 0 (top) to 100 (bottom).
reset boolean Copy the brand profile’s current captions, placement included, onto this video. Send it alone.

The captions of this one video, like the editor’s Captions panel. The brand profile is not changed. The video must be open for editing, and the new captions show once you export it. The response’s captions.position is where they sit ({ x_pct, y_pct, max_width_pct }), or null for the normal spot at the bottom centre. The box width is worked out for you, so the text stays on screen.

DELETE /videos/:id

Delete a video for good.

Permanent, and it takes the scenes, the narration and the edit history with it. This cannot be undone, and the credits the video cost are not returned. Use POST /videos/:id/archive unless the video is genuinely meant to be destroyed.

A video that is still building cannot be deleted. Wait for it to finish or fail first: the credits it reserved are spent either way.

Characters

A recurring character looks the same in every video of the profile it is attached to. Drawing one is a draft: nothing reaches a video until you approve it. Approving makes it the lead of the profile it was drawn on, and POST /brand-profiles/:id/characters sets a profile’s whole cast. List what you already have with GET /characters.

POST /characters

Draw a recurring character.

Body
brand_profile_id required uuid The profile this character belongs to.
name required string The character’s name, 1 to 60 characters.
description required string What they look like, 10 to 400 characters. Describe the person or creature only: species, build, face, hair, clothing, colours. Not the art style, a pose, a background, or anything they are holding. A prop written in here follows them into every scene.
visual_style_id uuid Draw them in this style instead of the profile’s own.

Each drawing comes out of a monthly free allowance, then costs a credit, and the response shows both, so settle the description first. Calling it again adds another take of the same character rather than making a second one, so this is both "make one" and "try again". The response says how many redraws are left. Needs an active paid plan.

The description it returns is not the one you sent: it is what we read back off the picture, and it is the text that travels into every scene. Judge that as much as the image. Nothing renders in a video until you approve a take.

POST /characters/describe

Reword a character, free.

Body
change required string The change to make, in plain words. For example "older" or "lose the hat".
description string The description to change. Send this or character_id.
character_id uuid Take the current description from this character instead of passing it.

Free and instant. Nothing is drawn and nothing is charged. Use it to settle the wording before spending a draw, because the description is what travels into every scene prompt. Pass the result to POST /characters, or to POST /characters/:id/approve to save it against a picture already drawn.

POST /characters/:id/approve

Save a character.

Body
take integer Which take to keep, from the takes list POST /characters returned. Defaults to the most recent.
description string Save this description instead of the drawn one. The picture is unchanged.

Saving fixes the picture and the description for good. Only the name can be changed afterwards, because a saved character has to stay the one the person judged. Be sure here, not after.

Approving also makes it the lead character of the profile it was drawn on, so every new video from that profile shows it. To use it in another profile, add it to that profile’s cast with POST /brand-profiles/:id/characters. A character only renders in the visual style it was drawn in.

DELETE /characters/:id

Throw a character away.

Throws a character away, saved or not. Any brand profile using it stops using it, so its next videos are drawn without a recurring character. Videos already made keep theirs.

PATCH /characters/:id

Rename a character.

Body
name required string The new name, 1 to 60 characters.

Works on a saved character too: the name is the one thing that can still change. Its @mention stays the same, so directions and prompts already written still find it.

YouTube

Put finished videos on your YouTube channel and see how they do. Connecting a channel is Google’s sign-in, so it happens in the dashboard, not over the API.

GET /youtube/channels

Your connected YouTube channels.

The channels connected to your account. can_upload says whether a channel can take an upload, and uploads_per_channel_per_day is the daily limit per channel.

POST /youtube/posts

Publish a video to YouTube.

Body
video_id required uuid A finished video.
privacy required string public, unlisted or private. Always ask the person.
channel_id uuid From GET /youtube/channels. Optional when only one channel is connected.
publish_at required string "now", or an ISO 8601 time that names its offset: Z for UTC, or e.g. -04:00. A time without one is refused. Up to a year ahead. Never assumed: say "now" to post at once.
title string Up to 100 characters. Default: the video’s title.
description string Up to 5000 characters. Default: the video’s description with its chapters.
tags string[] Default: the video’s tags.
category_id string A YouTube category id. Default 22 (People & Blogs).
thumbnail_version_id uuid Use an earlier thumbnail from GET /videos/:id/versions. It becomes the video’s thumbnail.
timezone string Your IANA timezone, for display. Default UTC.
allow_duplicate boolean Post again although this video already has a scheduled or published post on this channel. Refused (409) otherwise.

This posts to your channel, so confirm the channel, time, title and privacy first. A later time uploads it when that time comes, at the privacy you chose. Each channel can take a set number of uploads a day. Poll GET /youtube/posts for the YouTube link.

GET /youtube/posts

Your YouTube posts.

Query
video_id uuid Only this video’s posts.
status string scheduled, publishing, published, failed or cancelled.
limit integer 1 to 50. Default 20.
offset integer Skip this many. Take next_offset from the previous page.

Newest time first. A published post has its youtube_url, and a failed one says why.

PATCH /youtube/posts/:id

Change, cancel or retry a post.

Body
action string edit (the default), cancel, retry (a failed post only) or publish_now.
publish_at string A new ISO 8601 time that names its offset (Z, or e.g. -04:00).
title string Up to 100 characters.
description string Up to 5000 characters.
tags string[] Replaces the tags.
privacy string public, unlisted or private.
category_id string A YouTube category id.
allow_duplicate boolean Retry even though the video has gone out on this channel since. Refused (409, already_posted) otherwise.

Only a post that has not gone out: edit a scheduled, failed or cancelled one, cancel a scheduled one, retry a failed one (at once, unless you send a new publish_at), or publish a scheduled one now. A cancelled post is scheduled again with POST /youtube/posts. A published video is changed in YouTube Studio instead.

GET /youtube/analytics

Channel and video analytics.

Query
channel_id uuid One channel. All your channels if omitted.
video_id uuid One video’s figures instead of the channel’s: a Channel Farm video id, or a catalog_id from top_videos for a video not made here.
days integer 1 to 365. Default 28.

Views, watch time, subscribers and click-through over the last days, against the period before, plus your top videos. From YouTube’s own reporting, which runs a day or two behind.

Questions or a missing endpoint? Email hello@channel.farm. Create and manage tokens under Settings → Connect & API.