API Reference

API Reference

Generate AI videos programmatically. Submit a prompt, poll for completion, and receive a direct video URL — all via REST.

Base URL: https://yoh.app

Code examples will use the placeholder sk_live_YOUR_API_KEY until you sign in.

Quick Start

Create your first video in three API calls: generate → poll → download.

1. Generate a video
curl -X POST https://yoh.app/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "prompt": "A dragon teaches a young knight how to fly", "aspectRatio": "16:9" }'
Response — job enqueued
{
  "jobId": "cmnk8wu2q0001q3vpmqv93nmy",
  "status": "queued",
  "pollUrl": "/api/v1/stories/jobs/cmnk8wu2q0001q3vpmqv93nmy"
}
2. Poll until completed
curl https://yoh.app/api/v1/stories/jobs/cmnk8wu2q0001q3vpmqv93nmy \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response — job completed
{
  "status": "completed", "progress": 100,
  "storyId": "cmnk8xxnx000004lbd7siief7",
  "videoUrl": "https://cdn.example.com/video/clip.mp4",
  "error": null
}

That's it. The videoUrl is a publicly accessible URL you can download, stream, or share immediately.

Authentication

All API requests must include your API key in the Authorization header using the Bearer scheme.

Authorization: Bearer sk_live_YOUR_API_KEY

API keys begin with sk_live_. You can create and manage keys from the API Keys page. /api-keys

Keep your API keys secret. Do not expose them in client-side code or public repositories. If a key is compromised, revoke it immediately from the API Keys page.

Scopes & Credits

Each API key is granted one or more scopes that control which endpoints it can access. Story text generation costs 55 credits per job; scopes that also produce clip videos or a final render (including the default scope, full) charge for those up front on top. Credits are refunded if the job fails after all retries.

ScopeGrants access to
stories:generatePOST /api/v1/stories/generate, POST /stories/:id/assets, POST /stories/:id/storyboard, POST /stories/:id/videos, POST /stories/:id/render, POST /stories/:id/clips/:clipId/image, POST /stories/:id/clips/:clipId/video — the clip edits: POST/PATCH /stories/:id/clips, PATCH/DELETE /stories/:id/clips/:clipId — and the story-asset edits: PATCH /stories/:id/characters|scenes|items/:assetId. Also implies assets:write and series:write, so keys issued before those scopes existed keep working.
stories:readGET /api/v1/stories, GET /api/v1/stories/:id, GET /api/v1/stories/jobs/:id, GET /api/v1/credits, POST /api/v1/stories/estimate, POST /api/v1/stories/analyze-prompt. Implies series:read and assets:read.
assets:readGET .../characters, .../scenes, .../items, .../clips, GET /api/v1/series/:id/assets
assets:writePOST /api/v1/series/:id/assets, PATCH/DELETE /api/v1/series/:id/assets/:assetId, POST /api/v1/uploads
series:readGET /api/v1/series, GET /api/v1/series/:id
series:writePOST /api/v1/series, PATCH /api/v1/series/:id
social:publishPOST /api/v1/social/publish, GET /api/v1/social/publish/:postId
accounts:manageGET/POST /api/v1/accounts, GET/POST /api/v1/accounts/:accountId/credits. For platforms that serve many customers: provision an account per customer and fund it from your own balance. Implied by nothing — an existing key never gains it.

Generate a Video

Enqueue a video generation job

POST/api/v1/stories/generatescope: stories:generate

Submits a prompt and returns a jobId immediately. The video is generated asynchronously. Poll GET /api/v1/stories/jobs/:jobId to track progress.

Request body

FieldTypeStatusDescription
promptstringrequiredStory description, max 5000 characters.
aspectRatiostringoptionalOne of 16:9, 9:16, 1:1, 21:9. Default: 16:9.
renderVideobooleanoptionalfalse — returns the first AI clip (fast, ~10 min). true — renders all clips into one composed MP4 via Remotion (~12–15 min).
scopestringoptionalstory (text only, recommended), assets (+ character/scene/item images), storyboard (+ per-clip images), videos (+ clip videos), render (+ final MP4), or full (all phases). Default: full — which charges video + render credits up front. An unrecognized value falls back to full.
titlestringoptionalDisplay name for the story.
contentTypestringoptionalStory format category. One of cinematic_story, news_analysis, persona_channel, ad_spot. Default: cinematic_story.
localestringoptionalLanguage code, e.g. en, zh-TW. Default: en.
seriesIdstringoptionalAttach to an existing series for consistent character visuals.
assetsarrayoptionalThe cast and props for this story — reference assets already in your series library, or attach one-off assets inline. See the table below.
continuityModestringoptionalWith a seriesId: how much of the previous episodes this one carries. episodic_continuous (default) picks up open threads, finale resolves them, one_off_episode keeps series plot out entirely.
isFinalebooleanoptionalImplied by continuityMode: "finale"; you rarely need both.
allowMultiStorybooleanoptionalLet one request split into several episodes when the prompt clearly carries more than one. Default false — N episodes means N charges, so it is opt-in.
seriesContinuityContextobjectoptionalEscape hatch. Supplying it replaces the continuity we assemble from your series (open threads plus the last three episode recaps), so an incomplete object produces a weaker episode than sending nothing. Prefer continuityMode.

assets[] optional

This array ATTACHES assets to one generation — it is not where an asset is defined. It carries only what is read inline; the full asset shape (character type and role, voice, scene lighting and mood, item material and condition) lives in the series library, which accepts all of it and which the pipeline reads directly. Reference the library by id rather than restating an asset here. Each entry either references an asset in your series library by seriesAssetId (upload it once via POST /api/v1/series/:seriesId/assets and reuse it forever), or defines a one-off asset inline with type + name. The two forms mix freely in one array. Whatever you attach is described to the writer, and its image is reused instead of a new one being generated — so a character keeps their face and a product keeps its packaging.

FieldTypeDescription
seriesAssetIdstringReference an existing library asset. Everything else is optional when this is set.
typestringcharacter · scene · item · reference. Required for an inline asset.
namestringRequired for an inline asset. Also how the writer refers to it — name it in your prompt for it to appear.
descriptionstringOverrides the library description for this story only.
imageUrlstringImage for an inline asset. Re-hosted on our storage.
itemTypestringItems only: prop · product · logo. A logo is composited flat, never given 3D depth.
isHeroProductbooleanad_spot: the advertised product — appears in every clip. Fills heroItemId for you.
isPrimaryBackgroundbooleannews_analysis / persona_channel: the set reused across every shot. Fills primaryBackgroundAssetId for you.
useAsReferenceFramebooleanUse this image as the first frame / reference for image-to-video on clips featuring the asset.
genderstringCharacters only: surfaced to the writer.
agestringCharacters only: surfaced to the writer.
personalityTraitsstring[]Characters only: surfaced to the writer.
timeOfDaystringScenes only, e.g. dusk.
weatherstringScenes only, e.g. rain.
persistbooleanAccrue an inline asset into the series library so later stories can reference it by id. Default false.

Characters, scenes and items only enter the story when the prompt names them — attaching a whole series library does not drag its entire cast into every episode. The two pin flags are the exception: a pinned hero product or primary background is guaranteed regardless of whether the script says its name.

globalConfig optional

Optional nested object controlling duration, output format, and model selection. Every field has a default; pass only what you want to override. The model fields (videoModel, imageModel, modelStrategy) may also be passed at the top level as a convenience. Unknown model ids degrade to auto rather than failing the request.

This is a superset of the studio's own controls, not a mirror of them. resolution, qualityTier, modelStrategy and seed are API-only — the studio expresses the same choices through its quality picker. Two fields are advisory: they are shown to the writer as context but nothing enforces them, and they are marked below.

FieldTypeValuesDefault
durationSecnumber15–300 (total seconds)60
aspectRatiostring16:9 · 9:16 · 1:1 · 21:916:9
resolutionstring480p · 720p · 1080p720p
pacestringslow · normal · fastnormal
structurestringauto · 3_act · 4_act · montageauto
qualityTierstringdraft · balanced · premium (premium selects the advanced model lane)balanced
stylePresetstringpreset slug, or autoauto
platformTargetstringADVISORY — shown to the writer as context; nothing enforces it. youtube · tiktok · instagram · xyoutube
safetyModestringADVISORY — shown to the writer as context; not a content filter. standard · strictstandard
seedstringany string — shared across clips for continuity (API-only)
videoModelstringa model id, or autoauto
imageModelstringa model id, or autoauto
modelStrategystringauto · fixed · cost_optimized · quality_first · hero_premiumauto
generationModestringDEPRECATED — ignored. Every clip uses unified reference mode.auto
localestringen · zh-TW · ja · ko · th · id (also accepted top-level)en

contentTypeConfig optional

Optional per-contentType creative controls. Fields are validated against the selected content type; omitted fields fall back to sensible defaults. Pass the object matching your contentType. The two pin ids below are usually easier to set as isHeroProduct / isPrimaryBackground on an assets[] entry — an id you write here wins if you send both.

contentTypeFields
cinematic_story(no per-type fields — format craft is built in)
news_analysisprimaryBackgroundAssetId, primaryBackgroundName (pin a studio scene across anchor shots)
persona_channelprimaryBackgroundAssetId, primaryBackgroundName (pin the persona's set)
ad_spotheroItemId, heroProductName, heroTagline, heroKeyBenefit (the advertised product)

Other optional fields

FieldTypeDescription
storyQualityConfigobject{ enableStyleAdaptedCharacters?: boolean, directClipImageGeneration?: boolean } — quality-rollout toggles. Omit to use pipeline defaults.
styleobjectStyle-consistency overrides: cinematicStyle, era, colorPalette, lightingStyle, filmGrain, lensCharacter, materialEmphasis, styleCategory, negativeHints, stylePreset.
seriesContinuityContextobjectSeries continuity context for episodic generation. Used together with seriesId.

Fields that aren't part of the selected contentType's schema are silently ignored — cinematic_story takes no per-type fields at all, so pass contentTypeConfig only for ad_spot, news_analysis, or persona_channel.

Full control example
curl -X POST $BASE/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A 30s spot for a titanium water bottle that keeps ice for 24h",
    "contentType": "ad_spot",
    "scope": "full",
    "renderVideo": true,
    "globalConfig": {
      "durationSec": 30,
      "resolution": "1080p",
      "modelStrategy": "hero_premium"
    },
    "contentTypeConfig": {
      "heroProductName": "Northpeak Flask",
      "heroTagline": "Ice, 24 hours later.",
      "heroKeyBenefit": "Vacuum-sealed titanium keeps ice frozen for a full day"
    }
  }'

Response 202 Accepted

{ "jobId": "cmnk8wu2q0001q3vpmqv93nmy", "status": "queued", "pollUrl": "/api/v1/stories/jobs/..." }

Step-by-Step Endpoints

After generating a story with scope: "story", use these endpoints to generate storyboard images, clip videos, and the final rendered MP4 as separate steps.

POST/api/v1/stories/:storyId/assets

Generate the cast and world: character portraits + 2×2 character sheets, plus scene and item images. Run this before /storyboard for the best face consistency. Skips assets that already have images.

Credits: charged per generated image | Scope: stories:generate
POST/api/v1/stories/:storyId/storyboard

Generate storyboard grid images for all clips. Skips clips that already have images.

Credits: 20 minimum, then ~3 per clip beyond that | Scope: stories:generate
POST/api/v1/stories/:storyId/videos

Generate video for each clip. Skips clips that already have a video URL.

Credits: ~96 per 8s clip (default model; scales with duration) | Scope: stories:generate
POST/api/v1/stories/:storyId/render

Stitch all clip videos + music + captions into a final MP4.

Credits: 10 | Scope: stories:generate

One Clip at a Time

The batch endpoints above only fill in what's missing/storyboard skips clips that already have an image and /videos skips clips that already have a video. To re-roll a shot you don't like, target it directly. These are the same operations as the editor's per-clip regenerate buttons, and they overwrite whatever that clip already has.

POST/api/v1/stories/:storyId/clips/:clipId/image

(Re)generate the storyboard image for ONE clip. Optional body: prompt (art direction for this shot) and imageModel.

Credits: one image (no pipeline base) | Scope: stories:generate
POST/api/v1/stories/:storyId/clips/:clipId/video

(Re)generate the video for ONE clip. Optional body: videoModel. There is no mode to pick — every clip is produced in the single unified reference mode, where a multi-reference model receives one categorized bundle: the storyboard sketch and clip image, character sheets, item, background and style images, the previous clip's tail as a video reference, and the character's voice sample as an audio reference.

Credits: that clip's duration on the story's lane (returned as estimatedCredits) | Scope: stories:generate
Re-roll one shot
# Regenerate clip 3's image, then its video — the rest of the story is untouched
curl -X POST $BASE/api/v1/stories/STORY_ID/clips/CLIP_ID/image \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll jobId until completed

curl -X POST $BASE/api/v1/stories/STORY_ID/clips/CLIP_ID/video \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"videoModel":"bytedance/seedance-2.0"}'
# Poll jobId until completed → the clip's videoUrl is replaced

Editing the Story

Direct the story instead of only re-rolling it: rewrite a shot, retime it, add a beat, reorder, or cut one. These are immediate (no job to poll) and cost no credits. A clip's description is the prompt the generation pipelines read — edit it, then call that clip's /image or /video to re-shoot it with the new direction.

PATCH/api/v1/stories/:storyId/clips/:clipId

Edit a clip. Send only what you want to change: title, description, duration, generationDuration, resolution, transitionStyle, sceneId, characterIds, itemIds, dialogue, audioFx.

Media columns (imageUrl, videoUrl, generation history) are deliberately not writable — those belong to the pipelines. Use the clip's /image and /video endpoints to change them.

Credits: 0 | Scope: stories:generate
POST/api/v1/stories/:storyId/clips

Insert a clip. title and description required; place it with afterClipId or beforeClipId (omit both to append). Following clips shift down automatically. The new clip has no image or video yet.

Credits: 0 | Scope: stories:generate
PATCH/api/v1/stories/:storyId/clips

Reorder. Body: { "clips": [{ "id": "...", "order": 0 }, …] } — send every clip with a contiguous 0-based order.

Credits: 0 | Scope: stories:generate
DELETE/api/v1/stories/:storyId/clips/:clipId

Cut a clip; the remaining orders close up. A story must keep at least one clip.

Credits: 0 | Scope: stories:generate
Rewrite a shot, then re-shoot it
# 1. Rewrite the direction + retime the shot (immediate, free)
curl -X PATCH $BASE/api/v1/stories/STORY_ID/clips/CLIP_ID \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "The keeper climbs the stairs as the lamp flickers behind him.",
    "duration": 7
  }'

# 2. Re-shoot it with the new direction
curl -X POST $BASE/api/v1/stories/STORY_ID/clips/CLIP_ID/image \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
curl -X POST $BASE/api/v1/stories/STORY_ID/clips/CLIP_ID/video \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Step-by-step example
# Step 1: Generate story text (~1-2 min)
curl -X POST $BASE/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"A samurai cat guards a cherry blossom tree","contentType":"cinematic_story","scope":"story"}'
# Poll jobId until completed → get storyId

# Step 2: Generate storyboard images (~3-5 min)
curl -X POST $BASE/api/v1/stories/STORY_ID/storyboard \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll until completed

# Step 3: Generate clip videos (~1-3 min per clip)
curl -X POST $BASE/api/v1/stories/STORY_ID/videos \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll until completed

# Step 4: Render final MP4 (~1-2 min)
curl -X POST $BASE/api/v1/stories/STORY_ID/render \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll until completed → videoUrl

Poll Job Status

Get job status

GET/api/v1/stories/jobs/:jobIdscope: stories:read

Poll this endpoint after calling POST /api/v1/stories/generate. The job typically takes 8–15 minutes depending on story length and whether renderVideo is enabled. Recommend polling every 15–30 seconds.

Job status lifecycle

queuedgenerating_storygenerating_assetsgenerating_videosrendering_videocompletedorfailed

Which statuses you see depends on the scope you requested — a story job goes straight from generating_story to completed. A transient failure sends the job back to queued with error set to a retry message; that is not terminal, so treat only completed and failed as final states.

Response fields

FieldTypeDescription
jobIdstringUnique job identifier.
statusstringCurrent status. See lifecycle above.
progressnumber0–100 integer indicating completion percentage.
storyIdstring|nullSet once story generation completes. Use to fetch assets.
renderVideobooleanWhether a composed final MP4 was requested.
videoUrlstring|nullAbsolute video URL when status is completed. Either a composed MP4 (renderVideo: true) or the first clip's AI video (renderVideo: false).
errorstring|nullHuman-readable error message. Set on failure — and also while a job is being retried, where status returns to queued. Always null on success.
createdAt / startedAt / completedAtstring (ISO)Timestamps.

Stories

List stories

GET/api/v1/storiesscope: stories:read

Returns a paginated list of your stories.

Query parameters

ParamDefaultDescription
page1Page number (≥ 1).
limit20Results per page (1–100). Capped at 100.
statusFilter by story status.

This is the only endpoint that returns a pagination envelope alongside data:

{
  "data": [ { "id": "cmnk9cibl0004q3vpijkqfwl4", "title": "...", "status": "completed" } ],
  "pagination": { "page": 1, "limit": 20, "total": 137 }
}

Get a story

GET/api/v1/stories/:storyIdscope: stories:read

Returns full metadata for a single story including counts of its characters, scenes, items, and clips.

{
  "id": "cmnk9cibl0004q3vpijkqfwl4",
  "title": "A robot chef cooks in a futuristic kitchen",
  "status": "completed", "aspectRatio": "9:16", "locale": "en",
  "counts": { "characters": 3, "scenes": 4, "items": 6, "clips": 6 },
  "createdAt": "2026-04-04T11:37:04.662Z"
}

Assets overview

After a job completes you can fetch all assets generated for the story using the storyId returned by the job. All asset endpoints require the assets:read scope.

GET /api/v1/stories/:id/characters

Characters — name, description, role, imageUrl, voice info

GET /api/v1/stories/:id/scenes

Scenes — description, timeOfDay, weather, lighting, imageUrl

GET /api/v1/stories/:id/items

Props / items — description, significance, imageUrl

GET /api/v1/stories/:id/clips

Clips — narration, duration, videoUrl, imageUrl, audioUrl

Characters

List characters

GET/api/v1/stories/:storyId/charactersscope: assets:read
{
  "data": [{
    "id": "char_abc123", "name": "Chef Axon", "role": "protagonist",
    "description": "A robot chef with a warm personality",
    "imageUrl": "https://cdn.example.com/characters/chef-axon.jpg",
    "voiceName": "Neutral-EN", "order": 0
  }]
}

Clips & Videos

List clips

GET/api/v1/stories/:storyId/clipsscope: assets:read

Each clip is a scene segment with its own AI-generated video, still image, and audio. All URL fields are absolute and publicly accessible.

{
  "data": [{
    "id": "clip_001", "order": 0, "narration": "In the year 2157...",
    "duration": 8,
    "videoUrl": "https://cdn.example.com/generated/video/clip1.mp4",
    "imageUrl": "https://cdn.example.com/generated/image/clip1.jpg",
    "audioUrl": null, "status": "completed"
  }]
}

When renderVideo is false, the job's videoUrl points to the first clip's video. Use this endpoint to get the video URL for every individual clip.

Series Asset Library

Upload an asset once, reference it forever

POST/api/v1/series/:seriesId/assetsscope: assets:write

The same library the studio asset panel writes to. Upload a character, background, item or style reference, keep the returned id, and reference it at generation time by passing that id as seriesAssetId in the assets array — no need to mint a fresh image link per video. Images you pass are re-hosted on our storage, so the id keeps working after your own upload URL expires.

FieldTypeDescription
typestringcharacter, scene, item, or reference.
namestringDisplay name. Also the key that later generations match against.
imageUrlstringPublic URL of the image. Alternatively send imageBase64.
itemTypestringItems only: prop, product, or logo. A logo is composited flat, never given 3D depth.
isHeroProductbooleanItems only: the product this ad is about. It appears in every clip.
isPrimaryBackgroundbooleanScenes only: the studio/set reused across every anchor shot.
  • Send one asset, or an "assets" array to upsert up to 100 at once.
  • By default assets merge on an exact name match, so re-uploading the same catalog is idempotent. Pass ?mergeStrategy=fuzzy for rename-tolerant matching, or insert to always create a new asset.
  • Set isHeroProduct on an item (ad_spot) or isPrimaryBackground on a scene (news_analysis / persona_channel) to pin it. At generation time the pin resolves automatically — you never write heroItemId by hand.
ScopeGrants access to
GET/api/v1/series
POST/api/v1/series
GET/api/v1/series/:seriesId
PATCH/api/v1/series/:seriesId
GET/api/v1/series/:seriesId/assets?type=character
POST/api/v1/series/:seriesId/assets
PATCH/api/v1/series/:seriesId/assets/:assetId
DELETE/api/v1/series/:seriesId/assets/:assetId
POST/api/v1/uploads
PATCH/api/v1/stories/:storyId/characters/:assetId
PATCH/api/v1/stories/:storyId/scenes/:assetId
PATCH/api/v1/stories/:storyId/items/:assetId
POST/api/v1/stories/estimate
GET/api/v1/credits

End to end — put a brand's logo in an ad, and keep the id for every ad after it:

1 — create the show (once)
curl -X POST https://yoh.app/api/v1/series \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Acme brand films","styleGuide":{"cinematicStyle":"clean product cinematography"}}'
# → { "series": { "id": "series_abc", ... } }
2 — upload the logo (once) and keep the id
curl -X POST https://yoh.app/api/v1/series/series_abc/assets \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "type": "item",
        "name": "Acme Logo",
        "itemType": "logo",
        "description": "Wordmark, orange on white",
        "imageUrl": "https://your-admin.example.com/tmp/logo.png"
      }'
# → { "asset": { "id": "series-item-...", "imageUrl": "https://...blob..." } }
# That imageUrl is ours now — your temporary link can expire.
3 — generate an ad that features it (every time)
curl -X POST https://yoh.app/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "prompt": "A 30-second spot for the Acme cordless drill",
        "contentType": "ad_spot",
        "seriesId": "series_abc",
        "assets": [
          { "seriesAssetId": "series-item-...", "isHeroProduct": true }
        ]
      }'
# → { "jobId": "...", "pollUrl": "/api/v1/stories/jobs/..." }

Step 2 happens once per asset, not once per video. Step 3 repeats forever with the same id — and because the asset is pinned, the logo is guaranteed in every clip even if the script never says its name. To check the cost before committing, POST the same body to /api/v1/stories/estimate.

Series Assets

Register a character image for a series

POST/api/v1/series-assets/ensure-character-imagescope: assets:write

POST /api/v1/series-assets/ensure-character-image still works and is unchanged, but it only handles characters. Prefer this endpoint.

Uploads and persists a character reference image for a series so the AI uses a consistent face/look across all stories in that series. This operation is idempotent — calling it multiple times with the same character name is safe.

FieldTypeDescription
seriesIdstringID of the series to attach the character to.
characterNamestringThe character's name (used as a unique key within the series).
imageUrlstringPublicly accessible URL of the character reference image.

Publish to Social

Push a finished video straight to a connected account, so an automation can drive generate → render → publish with one credential. Connect the accounts in the app first; this endpoint publishes to them.

Publish a video

POST/api/v1/social/publishscope: social:publish

Returns 202 with { postId, status: "publishing" } and uploads in the background — poll the status endpoint below. Pass wait: true to block until the upload finishes instead (can take minutes).

FieldTypeStatusDescription
socialAccountIdstringrequiredA connected account owned by your key's user.
storyIdstringrequiredA story owned by your key's user.
videoUrlstringrequiredPublic MP4 URL — the videoUrl from a completed job.
platformstringrequiredfacebook · instagram · tiktok · threads · youtube
captionstringoptionalPost caption.
youtubeobjectoptional{ title, privacyStatus, categoryId, tags } — YouTube only.
durationSecnumberoptionalUsed for pre-flight platform validation.
aspectRatiostringoptionalUsed for pre-flight platform validation.
fileSizeBytesnumberoptionalUsed for pre-flight platform validation.
waitbooleanoptionalBlock until the upload completes. Default false.
curl -X POST $BASE/api/v1/social/publish \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "socialAccountId": "acct_123",
    "storyId": "STORY_ID",
    "videoUrl": "https://cdn.example.com/video/final.mp4",
    "platform": "youtube",
    "caption": "Episode 1 is live",
    "youtube": { "title": "The Lighthouse — Ep 1", "privacyStatus": "public" }
  }'

Poll publish status

GET/api/v1/social/publish/:postIdscope: social:publish

Lifecycle: publishingpublished | failed.

Managed Accounts

For platforms that serve many customers. Instead of one key and one credit pool for everybody — where one busy customer drains the balance for the rest and nobody can tell who spent what — provision an account per customer and fund each from your own balance. Your customers never sign up here or pay us; the commercial relationship stays yours.

Provision an account

POST/api/v1/accountsscope: accounts:manage

externalId is your own id for the customer. Calling twice with the same one returns the existing account instead of creating a second, so a retried request is safe. The API key comes back once, on first creation — we store a hash and cannot show it again, so persist it when you receive it. A new account starts at a zero balance.

FieldTypeDescription
externalIdstringRequired. Your id for this customer — a tenant id, say. Unique per platform.
namestringOptional label, for your own reporting.

Fund an account

POST/api/v1/accounts/:accountId/creditsscope: accounts:manage

Moves credits from your balance into the account's — a transfer, not a grant, so you can only hand out what you actually bought. Returns 402 when your own balance cannot cover it.:accountId accepts either our account id or your externalId.

FieldTypeDescription
amountintegerRequired. Credits to move. Must be positive.
idempotencyKeystringPass your invoice or subscription-period id. A retried or redelivered call then returns the original transfer instead of funding the account twice.
descriptionstringOptional note, shown on both sides of the transfer in the credit history.

GET /api/v1/accounts lists your accounts with each balance and lifetime spend; GET /api/v1/accounts/:accountId/credits reads one. A managed account's own key carries generation scopes only — it can never provision or fund accounts itself.

Manage API Keys

API key management endpoints use session authentication (logged-in user cookie), not an API key. Use the API Keys page for a browser-based workflow, or call these endpoints programmatically from your backend with a valid session. /api-keys

List API keys

GET/api/v1/api-keysscope: session
{
  "data": [{
    "id": "key_abc123", "name": "Production",
    "keyPrefix": "sk_live_7WH3",
    "scopes": ["stories:generate", "stories:read", "assets:read"],
    "revokedAt": null, "createdAt": "2026-04-03T10:00:00.000Z"
  }]
}

Create an API key

POST/api/v1/api-keysscope: session
// Response — full key shown once
{
  "id": "key_abc123",
  "key": "sk_live_...",
  "name": "My integration",
  "keyPrefix": "sk_live_7WH3",
  "scopes": ["stories:generate", "stories:read", "assets:read"]
}

Revoke an API key

DELETE/api/v1/api-keys/:idscope: session
{ "id": "key_abc123", "revoked": true }

Errors

All errors return a consistent JSON shape with an error object containing a machine-readable code.

{ "error": { "code": "insufficient_credits", "message": "...", "status": 402 } }
HTTPcodeMeaning
401unauthorizedMissing or invalid API key.
403forbiddenAPI key does not have the required scope.
404not_foundThe requested resource does not exist.
400bad_requestInvalid request body or parameters.
402insufficient_creditsNot enough credits to perform the operation.
429rate_limitedToo many requests. Check the Retry-After header.
500internal_errorUnexpected server error.

Rate limits: Generation endpoints allow 5 requests per minute per API key. Reads and clip edits (which spend no credits) allow 60. When you hit a limit, the response includes a Retry-After header with the number of seconds to wait. Failed jobs (after all retries) automatically refund the credits that were deducted.