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.
| Environment | API base URL | MCP URL |
|---|---|---|
| Production | https://app.doodaro.com/api/v1 | https://app.doodaro.com/api/mcp |
| Local studio | http://127.0.0.1:4318/api/v1 | http://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
- Open Developer in your dashboard. It sits at the bottom of the sidebar, directly above Settings.
- Give the key a name (1 to 60 characters), for example
Build server, and create it. - Copy the secret. It is shown once and never stored in plain text, so it cannot be shown again.
- Put it in your
.envfile asDOODARO_API_KEY.
# .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:
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/v1and/api/mcpnever 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": {"code": "not_found", "message": "Project not found."}} | HTTP | code | When |
|---|---|---|
| 400 | invalid_request | Body or query failed validation. |
| 401 | unauthorized | Missing, unknown or revoked key. |
| 404 | not_found | Project, scene, job or media not owned by this key's owner. |
| 409 | conflict | Project changed since expectedRevision, or a duplicate. |
| 409 | script_not_ready | Script edits not applied to scenes yet. |
| 409 | not_ready | Missing picture or narration before render, stale assets, plan not complete. |
| 415 | unsupported_media_type | Body is not JSON. |
| 429 | rate_limited | Too many requests or production limits reached. |
| 503 | unavailable | Provider or storage not configured or down. |
| 500 | internal | Anything 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.
-H "Idempotency-Key: generate-7f3c1c9e-2026-09-15" Rate limits
| Limit | Scope |
|---|---|
| 120 requests per minute | Per key |
| 30 spending requests per minute | Per account, across plan, generate and render |
| 20 active jobs, 60 per hour, 200 per day | Production 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:
{
"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
revisiongoes up on every save. Send it back asexpectedRevisionto avoid overwriting someone else's edit; a mismatch returns409 conflict.readinesstells you what is left. WhencanRenderis true you can render. Otherwiseblockerssays what is missing.scenes[].image.urlandscenes[].voice.urlpoint at get_media and need your key.latestRender.videoUrlis a public link.voice.overrideon a scene is the per-scene narrator, ornullwhen the scene uses the project voice.dashboardUrlopens 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.
| Status | Meaning |
|---|---|
missing | Nothing generated yet. |
queued | A job is waiting to start. |
running | A job is working on it. |
ready | Done and current. |
stale | Done, but the narration, voice, picture description or style changed after it was made. Generate again. |
failed | The last job failed. Generate again. |
Formats
| format | Size | Ratio |
|---|---|---|
landscape | 1280 × 720 | 16:9 |
portrait | 1080 × 1920 | 9:16 |
square | 1080 × 1080 | 1: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.
| status | Terminal | Meaning |
|---|---|---|
queued | No | Waiting to start. |
running | No | In progress. Read progress. |
complete | Yes | Finished. Read result. |
failed | Yes | Stopped with an error. Read error. |
cancelled | Yes | Cancelled with cancel_job or from the dashboard. |
uncertain | Yes | The 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.
| Operation | Method and path | MCP tool |
|---|---|---|
API index | GET/ | No |
OpenAPI document | GET/openapi.json | No |
Agent guide | GET/agent-guide | No |
get_account | GET/me | Yes |
list_styles | GET/styles | Yes |
list_voices | GET/voices | Yes |
list_engines | GET/engines | Yes |
list_projects | GET/projects | Yes |
create_project | POST/projects | Yes |
get_project | GET/projects/{projectId} | Yes |
update_project | PATCH/projects/{projectId} | Yes |
sync_project | POST/projects/{projectId}/sync | Yes |
update_scene | PATCH/projects/{projectId}/scenes/{sceneId} | Yes |
add_scene | POST/projects/{projectId}/scenes | Yes |
delete_scene | DELETE/projects/{projectId}/scenes/{sceneId} | Yes |
plan_scenes | POST/projects/{projectId}/plan | Yes |
apply_plan | POST/projects/{projectId}/plan/apply | Yes |
generate_assets | POST/projects/{projectId}/generate | Yes |
render_video | POST/projects/{projectId}/renders | Yes |
list_renders | GET/projects/{projectId}/renders | Yes |
get_job | GET/jobs/{jobId} | Yes |
cancel_job | DELETE/jobs/{jobId} | Yes |
get_media | GET/media/{assetId} | No |
Discovery
These three routes are public. They need no key.
API index
GEThttps://app.doodaro.com/api/v1
Names the API version and links to the OpenAPI document, the agent guide and the MCP endpoint.
curl https://app.doodaro.com/api/v1 {
"name": "...",
"version": "v1",
"openapi": "...",
"agentGuide": "...",
"mcp": "..."
} OpenAPI document
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.
curl https://app.doodaro.com/api/v1/openapi.json -o doodaro-openapi.json { "openapi": "3.1.0", "info": { ... }, "paths": { ... } } Agent guide
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.
curl https://app.doodaro.com/api/v1/agent-guide # Doodaro API guide for AI agents
... Account
get_account
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.
curl https://app.doodaro.com/api/v1/me \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"plan": "...",
"minutes": {"included": ..., "used": ..., "remaining": ...},
"key": {"id": "uuid", "name": "Build server", "prefix": "ddr_live_Q2xhYm"}
} Catalog
list_styles
GET/styles
Lists the 37 curated Doodaro styles and your saved templates. Style ids go into styleId.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
query | string | No | Filter by words in the title or description. |
curl "https://app.doodaro.com/api/v1/styles?query=chalkboard" \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"styles": [
{
"id": "doodaro:<versionId>",
"title": "Color Chalkboard",
"description": "...",
"previewUrl": "...",
"collection": "doodaro"
}
]
} list_voices
GET/voices
Lists narrators from the voice library. Pass the id and name as the project voice or a scene override.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
query | string | No | Filter by name or description. |
language | string | No | Language code, for example en. |
cursor | string | No | The nextCursor from the previous page. |
curl "https://app.doodaro.com/api/v1/voices?language=en&query=calm" \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"voices": [
{"id": "uuid", "name": "Damon", "description": "...", "language": "en", "accent": "...", "gender": "..."}
],
"nextCursor": "..."
} list_engines
GET/engines
Lists the drawing engine versions a project can pin, and the default.
curl https://app.doodaro.com/api/v1/engines \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"default": "doodle-0.3.0",
"engines": [
{"id": "doodle-0.3.0", "label": "..."}
]
} Projects
list_projects
GET/projects
Lists your projects, including the ones made in the dashboard.
curl https://app.doodaro.com/api/v1/projects \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"projects": [
{"id": "uuid", "title": "How planes fly", "format": "portrait", "sceneCount": 7, "updatedAt": "..."}
]
} create_project
POST/projects
Creates a project from your scenes or from a script. Returns the project detail.
Body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | No | Project title. |
format | "landscape" | "portrait" | "square" | No | Video format. See Formats. |
styleId | string | No | doodaro:<versionId> or mine:<versionId>, from GET /styles. |
voice | {id, name} | No | Project narrator, from GET /voices. |
language | string | No | Language code, for example en. |
engineVersion | string | No | Engine id from GET /engines. |
fps | number | No | Videos are 24 fps unless 30 is given. |
scenes | array (1 to 100) | One of scenes or script | Each scene: title, narration (up to 4,000 characters), visualDirection (up to 6,000 characters). |
script | string (up to 60,000 characters) | One of scenes or script | A full script. Doodaro splits it into scenes. |
planning | "paragraphs" | "assistant" | No | Only 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.
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..."}
]
}' {
"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
GET/projects/{projectId}
Returns the project detail. The server syncs finished image, voice and story jobs into the project first.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
curl https://app.doodaro.com/api/v1/projects/$PROJECT_ID \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"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
PATCH/projects/{projectId}
Changes project settings. Changing the style marks scene images stale.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
expectedRevision | number | No | The revision you last read. If the project changed since, you get 409 conflict. |
title | string | No | New title. |
format | "landscape" | "portrait" | "square" | No | New format. |
styleId | string | No | New style id from GET /styles. |
voice | {id, name} | No | New project narrator. |
language | string | No | Language code. |
engineVersion | string | No | Engine id from GET /engines. |
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"}' { "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... } // project detail sync_project
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
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
curl -X POST https://app.doodaro.com/api/v1/projects/$PROJECT_ID/sync \
-H "Authorization: Bearer $DOODARO_API_KEY" { "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... } // project detail Scenes
update_scene
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
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
sceneId | string (uuid) | Yes | The scene id from the project detail. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
expectedRevision | number | No | The revision you last read. |
title | string | No | Scene title. |
narration | string | No | Spoken text, up to 4,000 characters. |
visualDirection | string | No | Picture description, up to 6,000 characters. |
voice | {id, name} | null | No | Voice 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.
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."}' { "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... } // project detail add_scene
POST/projects/{projectId}/scenes
Adds a scene, placed after afterSceneId when you give one. Returns the project detail.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
afterSceneId | string | No | Insert after this scene. |
title | string | No | Scene title. |
narration | string | Yes | Spoken text, up to 4,000 characters. |
visualDirection | string | Yes | Picture description, up to 6,000 characters. |
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."}' { "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... } // project detail delete_scene
DELETE/projects/{projectId}/scenes/{sceneId}
Deletes one scene and returns the project detail.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
sceneId | string (uuid) | Yes | The scene id from the project detail. |
curl -X DELETE https://app.doodaro.com/api/v1/projects/$PROJECT_ID/scenes/$SCENE_ID \
-H "Authorization: Bearer $DOODARO_API_KEY" { "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... } // project detail Planning
plan_scenes
POST/projects/{projectId}/plan
Queues a story assistant job that splits the saved script into scenes with picture descriptions.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
mode | "plan" | "rewrite" | Yes | plan keeps every word of the script and adds scene breaks plus picture descriptions. rewrite may change wording. |
message | string | No | Extra instructions for the assistant. |
- Proposals are never applied automatically. Poll get_job until complete, then call apply_plan.
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"}' {
"job": {"id": "uuid", "type": "story", "status": "queued", "progress": 0, "error": null, "projectId": "uuid", "result": null}
} apply_plan
POST/projects/{projectId}/plan/apply
Applies a completed story plan to the project and returns the project detail.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string | Yes | The completed story job. |
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\"}" { "id": "uuid", "revision": 8, "readiness": { ... }, "scenes": [ ... ], ... } // project detail Generation
generate_assets
POST/projects/{projectId}/generate
Queues picture and narration generation for the project, or for chosen scenes.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
images | boolean | No | Generate pictures. Default true. |
voices | boolean | No | Generate narration. Default true. |
sceneIds | string[] | No | Only these scenes. Default every scene. |
onlyMissing | boolean | No | Skip 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").
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 '{}' {
"jobs": [
{"id": "uuid", "kind": "image", "type": "batch", "sceneCount": 7},
{"id": "uuid", "kind": "voice", "type": "batch", "sceneCount": 7}
]
} Renders
render_video
POST/projects/{projectId}/renders
Syncs the project, checks it can render, then queues a render with the project's saved engine version.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
kind | "video" | "scene" | No | The full video, or a preview of one scene. |
sceneId | string | With 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.
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"}' {
"render": {"id": "uuid", "status": "queued", "progress": 0}
} list_renders
GET/projects/{projectId}/renders
Lists the renders of a project.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
projectId | string (uuid) | Yes | The project id. |
curl https://app.doodaro.com/api/v1/projects/$PROJECT_ID/renders \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"renders": [
{"id": "uuid", "status": "complete", "progress": 100, "videoUrl": "https://media.doodaro.com/..."}
]
} Jobs
get_job
GET/jobs/{jobId}
Reads a story, image, voice, batch or render job. Poll every 3 to 5 seconds until a terminal status.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string (uuid) | Yes | The 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.
curl https://app.doodaro.com/api/v1/jobs/$JOB_ID \
-H "Authorization: Bearer $DOODARO_API_KEY" {
"id": "uuid",
"type": "render",
"status": "complete",
"progress": 100,
"error": null,
"projectId": "uuid",
"result": {"videoUrl": "https://media.doodaro.com/...", "finalFrameUrl": "..."}
} cancel_job
DELETE/jobs/{jobId}
Cancels a job and returns it in the same shape as get_job.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
jobId | string (uuid) | Yes | The job id returned by plan, generate or render. |
curl -X DELETE https://app.doodaro.com/api/v1/jobs/$JOB_ID \
-H "Authorization: Bearer $DOODARO_API_KEY" { "id": "uuid", "type": "voice", "status": "cancelled", ... } Media
get_media
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
| Field | Type | Required | Description |
|---|---|---|---|
assetId | string | Yes | From a scene image.url or voice.url. |
- Published videos use their public videoUrl directly, with no key.
curl https://app.doodaro.com/api/v1/media/$ASSET_ID \
-H "Authorization: Bearer $DOODARO_API_KEY" \
-o scene-1.png <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.
- Pick a style and a voice.
GET /stylesandGET /voices. Use the returned ids. - Create the project with scenes.
POST /projectswithtitle,format,styleId,voice,languageandscenes. KeepidanddashboardUrl. - Generate pictures and narration.
POST /projects/{projectId}/generatewith anIdempotency-Key. You get one image job and one voice job (batches when there is more than one scene). - Poll.
GET /jobs/{jobId}every 3 to 5 seconds until each job is terminal. - Check readiness.
GET /projects/{projectId}. The server syncs the finished assets. Continue whenreadiness.canRenderis true; otherwise fix whatblockerslists and generate again. - Render.
POST /projects/{projectId}/renderswith{"kind": "video"}and anIdempotency-Key. - Poll the render.
GET /jobs/{render.id}untilcomplete. - Download. Fetch
result.videoUrl. It is a public link, so no key is needed. SharedashboardUrlwith 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
// 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
# 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
.envor your secret manager. Add.envto.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
| Date | Version | Change |
|---|---|---|
| 2026-09-15 | v1 beta | First release: API keys in Developer, /api/v1 operations, OpenAPI document, agent guide, Copy for AI and the Doodaro MCP endpoint. |