New: the storyboard. Redraw or revoice one scene without redoing the video.

See how it works
Doodaro
API reference · v1 beta

Doodaro API reference

Create projects, generate pictures and narration, render MP4s and read the results. Plain HTTPS and JSON. Every operation here is also a Doodaro MCP tool with the same name.

Overview

Doodaro has one API. You reach it three ways: the Developer API from your own code, Copy for AI for coding agents, and Doodaro MCP for MCP clients. All three call the same operations. See Copy for AI and MCP for the last two.

EnvironmentAPI base URLMCP URL
Productionhttps://app.doodaro.com/api/v1https://app.doodaro.com/api/mcp
Local studiohttp://127.0.0.1:4318/api/v1http://127.0.0.1:4318/api/mcp

There is no separate API subdomain. Paths in this reference are relative to the base URL, so GET /me means GET https://app.doodaro.com/api/v1/me.

An API project is a normal studio project. Anything you make through the API opens in the dashboard, and anything you edit in the dashboard is visible through the API.

Example values below (ids, titles, counts, URLs shown as ...) are placeholders. Field names, types and allowed values follow the v1 contract. The machine-readable version is the OpenAPI 3.1 document.

Authentication

Get a key

  1. Open Developer in your dashboard. It sits at the bottom of the sidebar, directly above Settings.
  2. Give the key a name (1 to 60 characters), for example Build server, and create it.
  3. Copy the secret. It is shown once and never stored in plain text, so it cannot be shown again.
  4. Put it in your .env file as DOODARO_API_KEY.
.env
# .env (never commit this file)
DOODARO_API_KEY=ddr_live_...

A secret is ddr_live_ followed by 43 base64url characters (32 random bytes). The dashboard lists each key by name, prefix (the first 16 characters), last 4 characters, created date, last used date and revoked date. You can have up to 20 active keys. Revoke a key in Developer and it stops working on the next request.

Send the key

Send the key in the Authorization header on every request except the three public discovery routes:

header
Authorization: Bearer ddr_live_...

X-API-Key: ddr_live_... is also accepted. A missing, unknown or revoked key returns 401 unauthorized.

  • Cookies are ignored. /api/v1 and /api/mcp never use a dashboard session. Being logged in does not authenticate an API call.
  • No CORS. The API sends no CORS headers, so web pages on other sites cannot call it with a key. Call it from a server, a script or a coding agent.
  • The dashboard manages keys through its own session-only routes (/api/developer/keys). Those routes do not accept API keys and are not part of the public API.

Responses and errors

Send request bodies as JSON with Content-Type: application/json. Success bodies are JSON objects, except GET /agent-guide (markdown) and GET /media/{assetId} (file bytes). Create operations return 201; operations that queue work return 202.

Errors always have the same shape:

error
{"error": {"code": "not_found", "message": "Project not found."}}
HTTPcodeWhen
400invalid_requestBody or query failed validation.
401unauthorizedMissing, unknown or revoked key.
404not_foundProject, scene, job or media not owned by this key's owner.
409conflictProject changed since expectedRevision, or a duplicate.
409script_not_readyScript edits not applied to scenes yet.
409not_readyMissing picture or narration before render, stale assets, plan not complete.
415unsupported_media_typeBody is not JSON.
429rate_limitedToo many requests or production limits reached.
503unavailableProvider or storage not configured or down.
500internalAnything else. The message never includes internals.

Messages are safe to show to end users. No SQL, file paths, provider responses, prompts, compiled style recipes or secrets are ever returned. Branch on code, not on the message text.

Idempotency

POST operations that spend (plan_scenes, generate_assets, render_video) accept an Idempotency-Key header of 1 to 200 characters.

The server derives the production request id from your account, the operation, the project id and the key. Send the same key again, after a timeout or a dropped connection, and you get the same job back instead of paying twice. Use a new key for new work. Render jobs are also idempotent per project revision.

header
-H "Idempotency-Key: generate-7f3c1c9e-2026-09-15"

Rate limits

LimitScope
120 requests per minutePer key
30 spending requests per minutePer account, across plan, generate and render
20 active jobs, 60 per hour, 200 per dayProduction limits per account, shared with the dashboard

Over a limit you get 429 rate_limited with a Retry-After header. Wait that many seconds, then retry the same request (with the same Idempotency-Key if it spends). Per-key spending caps are planned and not enforced yet.

Data model

get_project and every operation that changes a project return the project detail:

project detail
{
  "id": "uuid",
  "title": "How planes fly",
  "revision": 7,
  "format": "portrait",
  "size": {"width": 1080, "height": 1920, "fps": 24},
  "engineVersion": "doodle-0.3.0",
  "style": {"id": "doodaro:<versionId>", "title": "Color Chalkboard"},
  "voice": {"id": "uuid", "name": "Damon"},
  "language": "en",
  "durationSeconds": 84,
  "readiness": {
    "scriptReady": true,
    "images": {"ready": 7, "total": 7},
    "voices": {"ready": 7, "total": 7},
    "canRender": true,
    "blockers": []
  },
  "scenes": [
    {
      "id": "uuid", "index": 0, "title": "The Everyday Miracle",
      "narration": "Every day, around a hundred thousand flights take off...",
      "visualDirection": "Vertical documentary composition at a busy airport...",
      "durationSeconds": 13.2,
      "image": {"status": "ready", "url": "/api/v1/media/<assetId>"},
      "voice": {"status": "ready", "url": "/api/v1/media/<assetId>", "durationSeconds": 11.8, "override": null}
    }
  ],
  "latestRender": {"id": "uuid", "status": "complete", "progress": 100, "videoUrl": "https://media.doodaro.com/..."},
  "dashboardUrl": "https://app.doodaro.com/?project=<id>#storyboard",
  "createdAt": "...", "updatedAt": "..."
}

Fields worth knowing

  • revision goes up on every save. Send it back as expectedRevision to avoid overwriting someone else's edit; a mismatch returns 409 conflict.
  • readiness tells you what is left. When canRender is true you can render. Otherwise blockers says what is missing.
  • scenes[].image.url and scenes[].voice.url point at get_media and need your key. latestRender.videoUrl is a public link.
  • voice.override on a scene is the per-scene narrator, or null when the scene uses the project voice.
  • dashboardUrl opens the project storyboard in the studio.

Asset status

Each scene's image.status and voice.status is one of these. They match the badges on the dashboard storyboard.

StatusMeaning
missingNothing generated yet.
queuedA job is waiting to start.
runningA job is working on it.
readyDone and current.
staleDone, but the narration, voice, picture description or style changed after it was made. Generate again.
failedThe last job failed. Generate again.

Formats

formatSizeRatio
landscape1280 × 72016:9
portrait1080 × 19209:16
square1080 × 10801:1

All formats are 24 fps unless fps 30 is given.

Style ids and engines

Style ids are doodaro:<versionId> for the 37 curated styles and mine:<versionId> for your saved templates. Both come from list_styles. Never build a style id by hand.

Every project pins a drawing engine version (engineVersion) so it keeps rendering the same way. list_engines returns the choices and the default, currently doodle-0.3.0.

Server-side sync

In the dashboard, the browser applies finished picture, narration and story results to the project. An API caller has no browser, so the server does the same work with the same shared code:

  • Finished image and voice jobs are written into their scenes.
  • A story plan is applied only when you call apply_plan.
  • Each job is recorded as handled, the same way the dashboard does it, so a job is never applied twice by either side.

Sync runs before get_project, before render_video, and when you call sync_project. It saves with the project's current revision and retries once on a conflict. If the dashboard is open at the same time, whichever side applies a job first wins; the other side sees it as already handled.

In practice: when your generate jobs reach complete, call get_project and read readiness. You do not need to call sync_project unless you want the sync without the read.

Jobs and polling

Planning, generation and rendering run in the background. The call returns 202 with a job (or jobs). Read a job with get_job every 3 to 5 seconds until it reaches a terminal status. There are no webhooks in v1.

statusTerminalMeaning
queuedNoWaiting to start.
runningNoIn progress. Read progress.
completeYesFinished. Read result.
failedYesStopped with an error. Read error.
cancelledYesCancelled with cancel_job or from the dashboard.
uncertainYesThe outcome could not be confirmed. Read the project before you retry.

Finished videos appear in the dashboard at the same moment, because they belong to the same project.

Operations

Every operation has a registry name. The same name is the MCP tool name. Discovery routes and get_media are HTTP only.

OperationMethod and pathMCP tool
API indexGET/No
OpenAPI documentGET/openapi.jsonNo
Agent guideGET/agent-guideNo
get_accountGET/meYes
list_stylesGET/stylesYes
list_voicesGET/voicesYes
list_enginesGET/enginesYes
list_projectsGET/projectsYes
create_projectPOST/projectsYes
get_projectGET/projects/{projectId}Yes
update_projectPATCH/projects/{projectId}Yes
sync_projectPOST/projects/{projectId}/syncYes
update_scenePATCH/projects/{projectId}/scenes/{sceneId}Yes
add_scenePOST/projects/{projectId}/scenesYes
delete_sceneDELETE/projects/{projectId}/scenes/{sceneId}Yes
plan_scenesPOST/projects/{projectId}/planYes
apply_planPOST/projects/{projectId}/plan/applyYes
generate_assetsPOST/projects/{projectId}/generateYes
render_videoPOST/projects/{projectId}/rendersYes
list_rendersGET/projects/{projectId}/rendersYes
get_jobGET/jobs/{jobId}Yes
cancel_jobDELETE/jobs/{jobId}Yes
get_mediaGET/media/{assetId}No

Discovery

These three routes are public. They need no key.

API index

Public 200

GEThttps://app.doodaro.com/api/v1

Names the API version and links to the OpenAPI document, the agent guide and the MCP endpoint.

Request
curl https://app.doodaro.com/api/v1
Response 200
{
  "name": "...",
  "version": "v1",
  "openapi": "...",
  "agentGuide": "...",
  "mcp": "..."
}

OpenAPI document

Public 200

GEThttps://app.doodaro.com/api/v1/openapi.json

OpenAPI 3.1 document generated from the operation registry. Use it to generate your own client or import into an API tool.

Request
curl https://app.doodaro.com/api/v1/openapi.json -o doodaro-openapi.json
Response 200
{ "openapi": "3.1.0", "info": { ... }, "paths": { ... } }

Agent guide

Public 200

GEThttps://app.doodaro.com/api/v1/agent-guide

Markdown instructions for AI agents, generated from the registry (text/markdown). It contains no secret. Copy for AI adds your key and the base URL on top.

Request
curl https://app.doodaro.com/api/v1/agent-guide
Response 200
# Doodaro API guide for AI agents
...

Account

get_account

Key 200

GET/me

Returns the plan, the minute counters and the key making the call. Use it to check that a key works.

  • Minutes are not metered during the beta, so treat the minute counters as informational.
Request
curl https://app.doodaro.com/api/v1/me \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "plan": "...",
  "minutes": {"included": ..., "used": ..., "remaining": ...},
  "key": {"id": "uuid", "name": "Build server", "prefix": "ddr_live_Q2xhYm"}
}

Catalog

list_styles

Key 200

GET/styles

Lists the 37 curated Doodaro styles and your saved templates. Style ids go into styleId.

Query parameters

FieldTypeRequiredDescription
querystringNoFilter by words in the title or description.
Request
curl "https://app.doodaro.com/api/v1/styles?query=chalkboard" \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "styles": [
    {
      "id": "doodaro:<versionId>",
      "title": "Color Chalkboard",
      "description": "...",
      "previewUrl": "...",
      "collection": "doodaro"
    }
  ]
}

list_voices

Key 200

GET/voices

Lists narrators from the voice library. Pass the id and name as the project voice or a scene override.

Query parameters

FieldTypeRequiredDescription
querystringNoFilter by name or description.
languagestringNoLanguage code, for example en.
cursorstringNoThe nextCursor from the previous page.
Request
curl "https://app.doodaro.com/api/v1/voices?language=en&query=calm" \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "voices": [
    {"id": "uuid", "name": "Damon", "description": "...", "language": "en", "accent": "...", "gender": "..."}
  ],
  "nextCursor": "..."
}

list_engines

Key 200

GET/engines

Lists the drawing engine versions a project can pin, and the default.

Request
curl https://app.doodaro.com/api/v1/engines \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "default": "doodle-0.3.0",
  "engines": [
    {"id": "doodle-0.3.0", "label": "..."}
  ]
}

Projects

list_projects

Key 200

GET/projects

Lists your projects, including the ones made in the dashboard.

Request
curl https://app.doodaro.com/api/v1/projects \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "projects": [
    {"id": "uuid", "title": "How planes fly", "format": "portrait", "sceneCount": 7, "updatedAt": "..."}
  ]
}

create_project

Key 201

POST/projects

Creates a project from your scenes or from a script. Returns the project detail.

Body

FieldTypeRequiredDescription
titlestringNoProject title.
format"landscape" | "portrait" | "square"NoVideo format. See Formats.
styleIdstringNodoodaro:<versionId> or mine:<versionId>, from GET /styles.
voice{id, name}NoProject narrator, from GET /voices.
languagestringNoLanguage code, for example en.
engineVersionstringNoEngine id from GET /engines.
fpsnumberNoVideos are 24 fps unless 30 is given.
scenesarray (1 to 100)One of scenes or scriptEach scene: title, narration (up to 4,000 characters), visualDirection (up to 6,000 characters).
scriptstring (up to 60,000 characters)One of scenes or scriptA full script. Doodaro splits it into scenes.
planning"paragraphs" | "assistant"NoOnly with script. Default paragraphs.
  • With scenes: you wrote everything. The project is created with those scenes, the script is the narrations joined by blank lines, and the plan is approved, so you can generate images and voices right away.
  • With script and planning "paragraphs" (the default): each paragraph becomes a scene with an empty picture description. readiness.blockers lists what is missing; fill visualDirection with update_scene.
  • With script and planning "assistant": the script is saved and a story plan job is queued. Poll get_job, then call apply_plan.
Request
curl -X POST https://app.doodaro.com/api/v1/projects \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "title": "How planes fly",
  "format": "portrait",
  "styleId": "doodaro:<versionId>",
  "voice": {"id": "voice-uuid", "name": "Damon"},
  "language": "en",
  "engineVersion": "doodle-0.3.0",
  "scenes": [
    {"title": "Hook", "narration": "Every day...", "visualDirection": "A busy airport at dawn..."}
  ]
}'
Response 201
{
  "id": "uuid",
  "title": "How planes fly",
  "revision": 7,
  "format": "portrait",
  "size": {"width": 1080, "height": 1920, "fps": 24},
  "engineVersion": "doodle-0.3.0",
  "style": {"id": "doodaro:<versionId>", "title": "Color Chalkboard"},
  "voice": {"id": "uuid", "name": "Damon"},
  "language": "en",
  "durationSeconds": 84,
  "readiness": {
    "scriptReady": true,
    "images": {"ready": 7, "total": 7},
    "voices": {"ready": 7, "total": 7},
    "canRender": true,
    "blockers": []
  },
  "scenes": [
    {
      "id": "uuid", "index": 0, "title": "The Everyday Miracle",
      "narration": "Every day, around a hundred thousand flights take off...",
      "visualDirection": "Vertical documentary composition at a busy airport...",
      "durationSeconds": 13.2,
      "image": {"status": "ready", "url": "/api/v1/media/<assetId>"},
      "voice": {"status": "ready", "url": "/api/v1/media/<assetId>", "durationSeconds": 11.8, "override": null}
    }
  ],
  "latestRender": {"id": "uuid", "status": "complete", "progress": 100, "videoUrl": "https://media.doodaro.com/..."},
  "dashboardUrl": "https://app.doodaro.com/?project=<id>#storyboard",
  "createdAt": "...", "updatedAt": "..."
}

get_project

Key 200

GET/projects/{projectId}

Returns the project detail. The server syncs finished image, voice and story jobs into the project first.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.
Request
curl https://app.doodaro.com/api/v1/projects/$PROJECT_ID \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "id": "uuid",
  "title": "How planes fly",
  "revision": 7,
  "format": "portrait",
  "size": {"width": 1080, "height": 1920, "fps": 24},
  "engineVersion": "doodle-0.3.0",
  "style": {"id": "doodaro:<versionId>", "title": "Color Chalkboard"},
  "voice": {"id": "uuid", "name": "Damon"},
  "language": "en",
  "durationSeconds": 84,
  "readiness": {
    "scriptReady": true,
    "images": {"ready": 7, "total": 7},
    "voices": {"ready": 7, "total": 7},
    "canRender": true,
    "blockers": []
  },
  "scenes": [
    {
      "id": "uuid", "index": 0, "title": "The Everyday Miracle",
      "narration": "Every day, around a hundred thousand flights take off...",
      "visualDirection": "Vertical documentary composition at a busy airport...",
      "durationSeconds": 13.2,
      "image": {"status": "ready", "url": "/api/v1/media/<assetId>"},
      "voice": {"status": "ready", "url": "/api/v1/media/<assetId>", "durationSeconds": 11.8, "override": null}
    }
  ],
  "latestRender": {"id": "uuid", "status": "complete", "progress": 100, "videoUrl": "https://media.doodaro.com/..."},
  "dashboardUrl": "https://app.doodaro.com/?project=<id>#storyboard",
  "createdAt": "...", "updatedAt": "..."
}

update_project

Key 200

PATCH/projects/{projectId}

Changes project settings. Changing the style marks scene images stale.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.

Body

FieldTypeRequiredDescription
expectedRevisionnumberNoThe revision you last read. If the project changed since, you get 409 conflict.
titlestringNoNew title.
format"landscape" | "portrait" | "square"NoNew format.
styleIdstringNoNew style id from GET /styles.
voice{id, name}NoNew project narrator.
languagestringNoLanguage code.
engineVersionstringNoEngine id from GET /engines.
Request
curl -X PATCH https://app.doodaro.com/api/v1/projects/$PROJECT_ID \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expectedRevision": 7, "title": "How planes really fly"}'
Response 200
{ "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... }  // project detail

sync_project

Key 200

POST/projects/{projectId}/sync

Applies finished jobs to the project now and returns the project detail. get_project and render_video do this for you.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.
Request
curl -X POST https://app.doodaro.com/api/v1/projects/$PROJECT_ID/sync \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{ "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... }  // project detail

Scenes

update_scene

Key 200

PATCH/projects/{projectId}/scenes/{sceneId}

Edits one scene. Changing narration or voice marks that scene's recording stale; changing the picture description marks its image stale.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.
sceneIdstring (uuid)YesThe scene id from the project detail.

Body

FieldTypeRequiredDescription
expectedRevisionnumberNoThe revision you last read.
titlestringNoScene title.
narrationstringNoSpoken text, up to 4,000 characters.
visualDirectionstringNoPicture description, up to 6,000 characters.
voice{id, name} | nullNoVoice override for this scene. null removes the override.
  • Narration edits keep the script and plan consistent the same way the dashboard storyboard does, so generation and render are not blocked by script_not_ready.
Request
curl -X PATCH https://app.doodaro.com/api/v1/projects/$PROJECT_ID/scenes/$SCENE_ID \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"visualDirection": "A wing cross-section with arrows showing lift."}'
Response 200
{ "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... }  // project detail

add_scene

Key 201

POST/projects/{projectId}/scenes

Adds a scene, placed after afterSceneId when you give one. Returns the project detail.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.

Body

FieldTypeRequiredDescription
afterSceneIdstringNoInsert after this scene.
titlestringNoScene title.
narrationstringYesSpoken text, up to 4,000 characters.
visualDirectionstringYesPicture description, up to 6,000 characters.
Request
curl -X POST https://app.doodaro.com/api/v1/projects/$PROJECT_ID/scenes \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title": "Landing", "narration": "Then the wheels touch down.", "visualDirection": "A plane landing at sunset."}'
Response 201
{ "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... }  // project detail

delete_scene

Key 200

DELETE/projects/{projectId}/scenes/{sceneId}

Deletes one scene and returns the project detail.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.
sceneIdstring (uuid)YesThe scene id from the project detail.
Request
curl -X DELETE https://app.doodaro.com/api/v1/projects/$PROJECT_ID/scenes/$SCENE_ID \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{ "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... }  // project detail

Planning

plan_scenes

Key 202 Idempotency-Key Spending limit

POST/projects/{projectId}/plan

Queues a story assistant job that splits the saved script into scenes with picture descriptions.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.

Body

FieldTypeRequiredDescription
mode"plan" | "rewrite"Yesplan keeps every word of the script and adds scene breaks plus picture descriptions. rewrite may change wording.
messagestringNoExtra instructions for the assistant.
  • Proposals are never applied automatically. Poll get_job until complete, then call apply_plan.
Request
curl -X POST https://app.doodaro.com/api/v1/projects/$PROJECT_ID/plan \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: plan-2026-09-15-01" \
  -d '{"mode": "plan"}'
Response 202
{
  "job": {"id": "uuid", "type": "story", "status": "queued", "progress": 0, "error": null, "projectId": "uuid", "result": null}
}

apply_plan

Key 200

POST/projects/{projectId}/plan/apply

Applies a completed story plan to the project and returns the project detail.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.

Body

FieldTypeRequiredDescription
jobIdstringYesThe completed story job.
Request
curl -X POST https://app.doodaro.com/api/v1/projects/$PROJECT_ID/plan/apply \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"jobId\": \"$JOB_ID\"}"
Response 200
{ "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... }  // project detail

Generation

generate_assets

Key 202 Idempotency-Key Spending limit

POST/projects/{projectId}/generate

Queues picture and narration generation for the project, or for chosen scenes.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.

Body

FieldTypeRequiredDescription
imagesbooleanNoGenerate pictures. Default true.
voicesbooleanNoGenerate narration. Default true.
sceneIdsstring[]NoOnly these scenes. Default every scene.
onlyMissingbooleanNoSkip scenes whose asset is ready and not stale. Default true.
  • Images need a picture description. Voices need narration and a voice (the project voice or a scene override).
  • One scene uses a single job (type "job"). More than one scene uses a batch (type "batch").
Request
curl -X POST https://app.doodaro.com/api/v1/projects/$PROJECT_ID/generate \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: generate-2026-09-15-01" \
  -d '{}'
Response 202
{
  "jobs": [
    {"id": "uuid", "kind": "image", "type": "batch", "sceneCount": 7},
    {"id": "uuid", "kind": "voice", "type": "batch", "sceneCount": 7}
  ]
}

Renders

render_video

Key 202 Idempotency-Key Spending limit

POST/projects/{projectId}/renders

Syncs the project, checks it can render, then queues a render with the project's saved engine version.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.

Body

FieldTypeRequiredDescription
kind"video" | "scene"NoThe full video, or a preview of one scene.
sceneIdstringWith kind "scene"The scene to preview.
  • The checks match the dashboard: script ready, no placeholder pictures, no stale assets, owned assets, background rules. If one fails you get 409 not_ready or script_not_ready.
  • The API does not change render settings. Render jobs are idempotent per project revision.
Request
curl -X POST https://app.doodaro.com/api/v1/projects/$PROJECT_ID/renders \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: render-2026-09-15-01" \
  -d '{"kind": "video"}'
Response 202
{
  "render": {"id": "uuid", "status": "queued", "progress": 0}
}

list_renders

Key 200

GET/projects/{projectId}/renders

Lists the renders of a project.

Path parameters

FieldTypeRequiredDescription
projectIdstring (uuid)YesThe project id.
Request
curl https://app.doodaro.com/api/v1/projects/$PROJECT_ID/renders \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "renders": [
    {"id": "uuid", "status": "complete", "progress": 100, "videoUrl": "https://media.doodaro.com/..."}
  ]
}

Jobs

get_job

Key 200

GET/jobs/{jobId}

Reads a story, image, voice, batch or render job. Poll every 3 to 5 seconds until a terminal status.

Path parameters

FieldTypeRequiredDescription
jobIdstring (uuid)YesThe job id returned by plan, generate or render.
  • Render results contain videoUrl and finalFrameUrl. Story results contain a proposal summary (title, scene count, scene titles). Batch results contain counts.
Request
curl https://app.doodaro.com/api/v1/jobs/$JOB_ID \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{
  "id": "uuid",
  "type": "render",
  "status": "complete",
  "progress": 100,
  "error": null,
  "projectId": "uuid",
  "result": {"videoUrl": "https://media.doodaro.com/...", "finalFrameUrl": "..."}
}

cancel_job

Key 200

DELETE/jobs/{jobId}

Cancels a job and returns it in the same shape as get_job.

Path parameters

FieldTypeRequiredDescription
jobIdstring (uuid)YesThe job id returned by plan, generate or render.
Request
curl -X DELETE https://app.doodaro.com/api/v1/jobs/$JOB_ID \
  -H "Authorization: Bearer $DOODARO_API_KEY"
Response 200
{ "id": "uuid", "type": "voice", "status": "cancelled", ... }

Media

get_media

Key 200

GET/media/{assetId}

Returns scene image or audio bytes. Needs your key; only your own assets. Scene url fields in the project detail point here.

Path parameters

FieldTypeRequiredDescription
assetIdstringYesFrom a scene image.url or voice.url.
  • Published videos use their public videoUrl directly, with no key.
Request
curl https://app.doodaro.com/api/v1/media/$ASSET_ID \
  -H "Authorization: Bearer $DOODARO_API_KEY" \
  -o scene-1.png
Response 200
<image or audio bytes>

End-to-end flow

The usual path from an idea to a downloaded MP4. The quickstart walks through it with curl.

  1. Pick a style and a voice. GET /styles and GET /voices. Use the returned ids.
  2. Create the project with scenes. POST /projects with title, format, styleId, voice, language and scenes. Keep id and dashboardUrl.
  3. Generate pictures and narration. POST /projects/{projectId}/generate with an Idempotency-Key. You get one image job and one voice job (batches when there is more than one scene).
  4. Poll. GET /jobs/{jobId} every 3 to 5 seconds until each job is terminal.
  5. Check readiness. GET /projects/{projectId}. The server syncs the finished assets. Continue when readiness.canRender is true; otherwise fix what blockers lists and generate again.
  6. Render. POST /projects/{projectId}/renders with {"kind": "video"} and an Idempotency-Key.
  7. Poll the render. GET /jobs/{render.id} until complete.
  8. Download. Fetch result.videoUrl. It is a public link, so no key is needed. Share dashboardUrl with anyone who will edit in the studio.

JavaScript and Python

There are no SDKs in v1. These scripts run the whole flow with the standard HTTP client of each language. Both handle the error object, 429 with Retry-After, idempotency keys and polling.

JavaScript (Node.js fetch)

doodaro-flow.mjs
// doodaro-flow.mjs
// Node.js 18 or newer (built-in fetch). Run on a server, not in a browser.
//   DOODARO_API_KEY=ddr_live_... node doodaro-flow.mjs
import { writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';

const BASE = 'https://app.doodaro.com/api/v1';
const KEY = process.env.DOODARO_API_KEY;
if (!KEY) throw new Error('Set DOODARO_API_KEY first.');

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const TERMINAL = new Set(['complete', 'failed', 'cancelled', 'uncertain']);

// One helper for every call: auth header, JSON body, error object, 429 retry.
async function api(method, path, body, { idempotencyKey } = {}) {
  for (let attempt = 0; ; attempt++) {
    const headers = { Authorization: `Bearer ${KEY}` };
    if (body !== undefined) headers['Content-Type'] = 'application/json';
    if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

    const res = await fetch(BASE + path, {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
    });

    if (res.status === 429 && attempt < 5) {
      const seconds = Number(res.headers.get('Retry-After')) || 5;
      await sleep(seconds * 1000);
      continue;
    }

    const data = await res.json().catch(() => ({}));
    if (!res.ok) {
      const error = new Error(`${res.status} ${data.error?.code}: ${data.error?.message}`);
      error.status = res.status;
      error.code = data.error?.code;
      throw error;
    }
    return data;
  }
}

async function waitForJob(jobId) {
  for (;;) {
    const job = await api('GET', `/jobs/${jobId}`);
    console.log(`${job.type} job ${job.id}: ${job.status} ${job.progress ?? 0}%`);
    if (TERMINAL.has(job.status)) return job;
    await sleep(4000); // poll every 3 to 5 seconds
  }
}

// 1. Pick a style and a voice. Never invent ids.
const { styles } = await api('GET', '/styles?query=chalkboard');
const { voices } = await api('GET', '/voices?language=en');
const style = styles[0];
const voice = voices[0];

// 2. Create a project with your own scenes.
let project = await api('POST', '/projects', {
  title: 'How planes fly',
  format: 'portrait',
  styleId: style.id,
  voice: { id: voice.id, name: voice.name },
  language: 'en',
  scenes: [
    {
      title: 'Hook',
      narration: 'Every day, around a hundred thousand flights take off. Here is how a wing holds them up.',
      visualDirection: 'A busy airport at dawn, planes lined up on the runway.',
    },
    {
      title: 'Lift',
      narration: 'A wing pushes air down. The air pushes the wing up. That push is called lift.',
      visualDirection: 'A wing cross-section with arrows showing air flowing down and lift pointing up.',
    },
  ],
});
console.log('Project:', project.id, project.dashboardUrl);

// 3. Generate pictures and narration. The Idempotency-Key makes a retry safe.
const run = randomUUID();
const { jobs } = await api('POST', `/projects/${project.id}/generate`, {}, { idempotencyKey: `generate-${run}` });
const finished = await Promise.all(jobs.map(job => waitForJob(job.id)));
if (finished.some(job => job.status !== 'complete')) throw new Error('Generation did not complete.');

// 4. Read the project. get_project syncs finished assets first.
project = await api('GET', `/projects/${project.id}`);
if (!project.readiness.canRender) {
  throw new Error(`Not ready to render: ${JSON.stringify(project.readiness.blockers)}`);
}

// 5. Render the video and wait for it.
const { render } = await api('POST', `/projects/${project.id}/renders`, {}, { idempotencyKey: `render-${run}` });
const done = await waitForJob(render.id);
if (done.status !== 'complete') throw new Error(`Render ${done.status}: ${JSON.stringify(done.error)}`);

// 6. Download the MP4. videoUrl is a public link, so no key is sent.
const video = await fetch(done.result.videoUrl);
await writeFile('how-planes-fly.mp4', Buffer.from(await video.arrayBuffer()));
console.log('Saved how-planes-fly.mp4. Open in the dashboard:', project.dashboardUrl);

Python (requests)

doodaro_flow.py
# doodaro_flow.py
# Python 3.9 or newer. pip install requests
#   DOODARO_API_KEY=ddr_live_... python doodaro_flow.py
import json
import os
import time
import uuid

import requests

BASE = "https://app.doodaro.com/api/v1"
KEY = os.environ["DOODARO_API_KEY"]
TERMINAL = {"complete", "failed", "cancelled", "uncertain"}

session = requests.Session()
session.headers["Authorization"] = f"Bearer {KEY}"


class DoodaroError(Exception):
    def __init__(self, status, code, message):
        super().__init__(f"{status} {code}: {message}")
        self.status, self.code = status, code


def api(method, path, body=None, idempotency_key=None):
    """One helper for every call: auth header, JSON body, error object, 429 retry."""
    headers = {}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    for attempt in range(6):
        res = session.request(method, BASE + path, json=body, headers=headers, timeout=60)
        if res.status_code == 429 and attempt < 5:
            time.sleep(int(res.headers.get("Retry-After", "5")))
            continue
        try:
            data = res.json()
        except ValueError:
            data = {}
        if not res.ok:
            err = data.get("error", {})
            raise DoodaroError(res.status_code, err.get("code"), err.get("message"))
        return data


def wait_for_job(job_id):
    while True:
        job = api("GET", f"/jobs/{job_id}")
        print(f"{job['type']} job {job['id']}: {job['status']} {job.get('progress') or 0}%")
        if job["status"] in TERMINAL:
            return job
        time.sleep(4)  # poll every 3 to 5 seconds


# 1. Pick a style and a voice. Never invent ids.
style = api("GET", "/styles?query=chalkboard")["styles"][0]
voice = api("GET", "/voices?language=en")["voices"][0]

# 2. Create a project with your own scenes.
project = api("POST", "/projects", {
    "title": "How planes fly",
    "format": "portrait",
    "styleId": style["id"],
    "voice": {"id": voice["id"], "name": voice["name"]},
    "language": "en",
    "scenes": [
        {
            "title": "Hook",
            "narration": "Every day, around a hundred thousand flights take off. Here is how a wing holds them up.",
            "visualDirection": "A busy airport at dawn, planes lined up on the runway.",
        },
        {
            "title": "Lift",
            "narration": "A wing pushes air down. The air pushes the wing up. That push is called lift.",
            "visualDirection": "A wing cross-section with arrows showing air flowing down and lift pointing up.",
        },
    ],
})
print("Project:", project["id"], project["dashboardUrl"])

# 3. Generate pictures and narration. The Idempotency-Key makes a retry safe.
run = uuid.uuid4().hex
jobs = api("POST", f"/projects/{project['id']}/generate", {}, idempotency_key=f"generate-{run}")["jobs"]
finished = [wait_for_job(job["id"]) for job in jobs]
if any(job["status"] != "complete" for job in finished):
    raise SystemExit("Generation did not complete.")

# 4. Read the project. get_project syncs finished assets first.
project = api("GET", f"/projects/{project['id']}")
if not project["readiness"]["canRender"]:
    raise SystemExit("Not ready to render: " + json.dumps(project["readiness"]["blockers"]))

# 5. Render the video and wait for it.
render = api("POST", f"/projects/{project['id']}/renders", {}, idempotency_key=f"render-{run}")["render"]
done = wait_for_job(render["id"])
if done["status"] != "complete":
    raise SystemExit(f"Render {done['status']}: {json.dumps(done.get('error'))}")

# 6. Download the MP4. videoUrl is a public link, so no key is sent.
with requests.get(done["result"]["videoUrl"], stream=True, timeout=300) as video:
    video.raise_for_status()
    with open("how-planes-fly.mp4", "wb") as f:
        for chunk in video.iter_content(chunk_size=1 << 20):
            f.write(chunk)
print("Saved how-planes-fly.mp4. Open in the dashboard:", project["dashboardUrl"])

Security

  • Keep the key on the server, in .env or your secret manager. Add .env to .gitignore.
  • Never put a key in browser code, a mobile app or a public repo. The API has no CORS, so browser calls fail anyway.
  • Create one key per app, server or agent, with a clear name. Revoke that one key if it leaks.
  • Revoke keys you no longer use. Watch last used in Developer for keys that should be idle.
  • Treat Copy for AI text as a secret: it contains a key.
  • Only send the key to https://app.doodaro.com. Do not paste it into tools or chats you do not control.

Changelog

DateVersionChange
2026-09-15v1 betaFirst release: API keys in Developer, /api/v1 operations, OpenAPI document, agent guide, Copy for AI and the Doodaro MCP endpoint.

Rather make it in the studio?

The API drives the same studio. Start with 2 free minutes, or open Developer in your dashboard to create a key.