Your first Doodaro video from the API
Eight requests take you from a new key to a downloaded MP4. You write two scenes, Doodaro draws and narrates them, and the finished project also opens in your dashboard.
Before you start
- A Doodaro account. Create one free, no card needed. Every account has its own workspace and its own API keys.
- A terminal with
curl. For the script at the end, Node.js 18 or newer. - The 5 minutes are your time. Generation and rendering run on the server and take as long as they take; you poll while they work.
1. Create a key
Open Developer in your dashboard (bottom of the sidebar, above Settings). Name the key, for example Quickstart, and create it. Copy the secret now: it is shown once.
export DOODARO_API_KEY="ddr_live_..." For an app, put it in .env instead and keep that file out of git.
2. Check the key
curl https://app.doodaro.com/api/v1/me \
-H "Authorization: Bearer $DOODARO_API_KEY" You get your plan, the minute counters and the key's name and prefix. 401 unauthorized means the key is missing, mistyped or revoked.
3. Pick a style and a voice
curl "https://app.doodaro.com/api/v1/styles?query=chalkboard" \
-H "Authorization: Bearer $DOODARO_API_KEY" Copy one style id. It looks like doodaro:<versionId>.
curl "https://app.doodaro.com/api/v1/voices?language=en" \
-H "Authorization: Bearer $DOODARO_API_KEY" Copy one voice id and its name. Always use real ids from these lists. Never make one up.
4. Create the project
Send your scenes. Each one has narration (what the voice says) and a visual direction (what gets drawn). Replace the two placeholders with the ids from step 3.
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 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."}
]
}' The response is 201 with the full project detail. Save the id, and open dashboardUrl if you want to watch the storyboard fill in.
export PROJECT_ID="<id from the response>" 5. Generate pictures and narration
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: quickstart-generate-1" \
-d '{}' An empty body means: every scene, pictures and voices, skipping anything already ready. The response is 202 with one job per kind:
{
"jobs": [
{"id": "uuid", "kind": "image", "type": "batch", "sceneCount": 2},
{"id": "uuid", "kind": "voice", "type": "batch", "sceneCount": 2}
]
} The Idempotency-Key header means you can safely resend this exact request if your connection drops. You get the same jobs back instead of a second set.
6. Poll the jobs, then check the project
# Repeat every 3 to 5 seconds for each job id until status is terminal
curl https://app.doodaro.com/api/v1/jobs/$JOB_ID \
-H "Authorization: Bearer $DOODARO_API_KEY" Wait until each job's status is complete. failed, cancelled and uncertain also end the job; read error and the project before retrying. Then read the project. The server applies the finished pictures and narration first.
curl https://app.doodaro.com/api/v1/projects/$PROJECT_ID \
-H "Authorization: Bearer $DOODARO_API_KEY"
# look for "readiness": {"canRender": true, "blockers": []} If canRender is false, blockers says why, for example a scene without a picture description.
7. Render the video
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: quickstart-render-1" \
-d '{"kind": "video"}' The response is 202 with a render object. Poll its id with GET /jobs/{id} the same way as step 6.
8. Download the MP4
When the render job is complete, its result has videoUrl and finalFrameUrl. The video link is public, so no key is needed.
# after get_job on the render id returns "status": "complete"
curl -L "<result.videoUrl>" -o how-planes-fly.mp4 That's it. The same video is in your dashboard under the project, where you can redraw or revoice one scene and render again.
The same flow in JavaScript
One file, no dependencies. Save it as doodaro-flow.mjs and run DOODARO_API_KEY=ddr_live_... node doodaro-flow.mjs. The Python version is in the API reference.
// 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); Troubleshooting
| You see | What to do |
|---|---|
401 unauthorized | Check the header is Authorization: Bearer ddr_live_... and the key is not revoked in Developer. |
400 invalid_request | Read message. Common causes: no scenes or script, or a narration over 4,000 characters. |
415 unsupported_media_type | Add -H "Content-Type: application/json" and send valid JSON. |
409 not_ready on render | A picture or narration is missing or stale. Read readiness.blockers, generate again, then render. |
429 rate_limited | Wait the seconds in Retry-After. Production limits are 20 active jobs, 60 per hour and 200 per day. |
503 unavailable | A provider or storage is down. Retry later with the same Idempotency-Key. |
| Captions missing from the MP4 | Expected in the beta. Caption settings are saved but not drawn into exports yet. |
Next steps
- API reference: edit scenes, use a script instead of scenes, plan with the story assistant, render one scene as a preview.
- Copy for AI and MCP: let Claude Code, Codex or Cursor run this flow for you.