Courses
A course turns one source - a document or an existing video - into a set of modules, each of which becomes exactly one video.
The pipeline runs in three steps, and each is asynchronous (or build a module in one call with
POST /v1/courses/:id/build):
- Create → the source is extracted and split into modules
- Storyboard → a module gets the scenes its video will be made of
- Generate → a module becomes a video made of editable slides
Every step returns a jobId. Poll GET /v1/jobs/:id exactly as you would for any other
async operation.
Create a course
POST /v1/courses
Give either a fileUrl + filename, or a sourceVideoId - not both.
| Field | Type | Description |
|---|---|---|
fileUrl | string | Public URL of the document (PDF, DOCX, PPTX, Keynote, TXT) |
filename | string | Required with fileUrl - its extension decides which extractor runs |
sourceVideoId | string | Build from an existing video instead of a document |
instructions | string | Guidance for the planner: audience, tone, what to emphasise |
pace | string | How modules are written and cut: snackable, course (default) or inDepth. See Pace |
aiVideo | object | { "enabled": true, "stylePresetId": "documentary" }: AI pictures and clips instead of stock photos. Business and Enterprise only. See AI video |
curl -X POST https://api.vidocu.ai/v1/courses \
-H "Authorization: Bearer vdo_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"fileUrl": "https://example.com/onboarding-handbook.pdf",
"filename": "onboarding-handbook.pdf",
"instructions": "For new warehouse staff. Keep each module under two minutes."
}'
Response 202 Accepted
{ "courseId": "dvp_1786480000000_a1b2c3d4", "jobId": "doc_plan_...", "status": "extracting" }
When that job completes, GET /v1/courses/:id shows the modules the planner proposed.
The document is fetched by our servers, so it must be publicly reachable - internal and private addresses are refused.
Pace
pace decides how long modules are, how fast scenes change and how the narration sounds.
| Value | Feels like | Modules | Scenes | Voice and captions |
|---|---|---|---|---|
snackable | Short-form social video | 1-3 min, more of them | 4-9 s each, a hook in the first 3 s | Energetic creator voice, big bold captions |
course | Online course (default) | 3-6 min | 8-15 s each | Friendly explainer |
inDepth | In-depth training | 5-12 min | 15-30 s each, ends with a key-rules checklist | Calm and precise |
A snackable course never drops content to stay short: it gets more, shorter modules instead. Safety warnings and procedures are always kept.
AI video
With aiVideo.enabled, scenes that the source has no picture for get an AI-generated still
instead of a stock photo, and a few of them a short AI clip that loops behind the narration.
Your document's own images and your recording's own frames still come first.
- Plans: Business and Enterprise, trials included. Other plans get
403 limit_exceeded. - Style:
stylePresetIdis one of the AI footage presets.documentary(default),cinematic,cartoon3d,flat2d,storybookandbrandare the ones the dashboard offers. - Clips are for context only: an opening hook, why it matters, the workplace. A procedure step is never shown with an AI picture, because AI pictures can get those details wrong.
- Clips per module: up to 4 (
snackable), 2 (course) or 1 (inDepth). - Credits: each picture is charged as
ai_footage_still, each clip asai_footage_clip, on success only, on top of the build (doc_video_generate, 50 credits per minute of video). A typicalcourse-pace module (about 4 minutes) costs about 214 credits, or 262 with AI video. Generate checks the most a module can cost before it starts and returns403 limit_exceededif the balance can't cover it. - Failures don't fail the module: a picture that can't be made becomes a text slide, and a clip
that can't be made shows its still. Both are listed in the module's
warnings. - Marking: exported videos with AI pictures say so in the file's metadata (IPTC
compositeWithTrainedAlgorithmicMedia).
In the storyboard, a scene's aiVisual is the picture it asked for (kind is still or clip,
shot describes it). After generate, imageUrl holds the still and aiClipUrl the clip.
List and read
GET /v1/courses · GET /v1/courses/:id
{
"id": "dvp_...",
"title": "Onboarding handbook",
"status": "plan_ready",
"source": { "filename": "onboarding-handbook.pdf", "kind": "pdf", "videoId": null },
"pace": "course",
"aiVideo": { "enabled": false, "stylePresetId": null },
"moduleCount": 4,
"modules": [
{
"id": "dm_...",
"title": "Receiving a delivery",
"objective": "Check in a pallet against its work order",
"status": "ready_to_generate",
"sourcePages": [3, 4, 5],
"targetDurationSec": 95,
"sceneCount": 7,
"videoId": null
}
]
}
GET /v1/courses supports status, page/limit and cursor.
Storyboard
GET /v1/courses/:id/storyboard returns every module's scenes, plus two fields worth reading:
omissions- source content the storyboard deliberately left out. Reviewable rather than silent, so you can tell "it missed this" from "it chose to skip this".warnings- non-blocking review hints.
POST /v1/courses/:id/storyboard builds one module's storyboard:
curl -X POST https://api.vidocu.ai/v1/courses/dvp_.../storyboard \
-H "Authorization: Bearer vdo_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"moduleId": "dm_..."}'
Returns 409 if that module is already being worked on - only one step runs per module at a time.
Build a module in one step
POST /v1/courses/:id/build
The dashboard's Create video button: storyboard (when the module has none yet), AI visuals when the course has AI video on, narration and slides, in one call.
curl -X POST https://api.vidocu.ai/v1/courses/dvp_.../build \
-H "Authorization: Bearer vdo_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"moduleId": "dm_..."}'
{ "jobId": "course_module_...", "videoId": "3f1c...", "projectId": "...", "async": true }
The video exists right away (status in_progress) and fills in as the job runs. A module that is
already ready is rebuilt, and its old video is replaced. Returns 409 while the module is being
built, and 403 limit_exceeded when the balance can't cover the build.
The two steps below remain for callers that want to review or edit the storyboard before generating.
Generate
POST /v1/courses/:id/generate
Builds one module into a video. It needs a storyboard first. Same body, same 409 rule.
The video is made of slides, one per storyboard scene, each with its own voiceover and word
timings - nothing is rendered until export, so the layout, text, picture and narration of every
scene stay editable in the dashboard's video editor afterwards. When the job completes, the
module's videoId points at an ordinary video - so export,
translate and everything else work on it normally.
Who can take it, and what counts as finishing
A course is shared as one link. PATCH /v1/courses/:id with a settings object decides what
happens when somebody opens it.
{
"settings": {
"access": {
"identity": "email_verified",
"openEnrollment": false,
"allowedEmailDomains": ["company.com"],
"allowRoster": true
},
"completion": { "rule": "watch_quiz", "watchThresholdPct": 90 },
"trackProgress": true
}
}
identity is what a learner has to give before the course opens:
| Value | What it means |
|---|---|
anonymous | No gate. Progress is counted per browser - a visitor count, not a training record |
name | A name, typed in and not checked |
email_name | A name and an email, typed in and not checked |
email_verified | An email the learner proves by opening a link we send them |
auth | A Vidocu account |
Only email_verified and auth produce records you can stand behind. The other three are
completion rates.
The audience rules are additive - an address gets in if any of them admits it: openEnrollment
(anyone who identifies), a matching entry in allowedEmailDomains, membership of invitedEmails,
or allowRoster plus a place on the workspace's training roster. accessCode is separate: a
shared code checked at the door, before anybody is asked who they are. Send "" or null to
clear it. It is stored hashed and never returned.
completion.rule decides when a module is done: watch (past watchThresholdPct of its runtime),
watch_quiz (that, and a passing attempt at the module's quiz - modules without a quiz fall back
to watch), or manual (the learner presses a button). Watched time only accrues while the video
is playing, so seeking to the end does not complete a module.
completion.sequential locks every module past the first unfinished one. It is enforced on the
learner page, on the per-module payload and on the progress endpoint, so skipping ahead is refused
whether it is attempted by clicking or by calling the API. A workspace member previewing their own
course is exempt.
Settings are merged. A patch naming completion.rule leaves the invite list alone.
Making the link reachable at all is still the share setting: a private course is workspace members
only, whatever access says.
Course progress
GET /v1/courses/:id/progress
{
"courseId": "dvp_...",
"moduleCount": 4,
"tracking": true,
"completionRule": "watch_quiz",
"totals": { "people": 31, "started": 24, "completed": 18, "completionRate": 58, "avgCompletedModules": 3.1 },
"modules": [
{ "moduleId": "dmod_...", "title": "Lockout procedure", "started": 24, "completed": 21,
"completionRate": 88, "avgWatchedPct": 94, "hasQuiz": true, "quizAttempts": 29,
"quizPassed": 21, "quizPassRate": 72 }
],
"people": [
"source": "progress", "anonymous": false, "completedCount": 4, "completedAll": true,
"startedAt": "2026-09-01T09:12:00.000Z", "lastSeenAt": "2026-09-03T14:40:00.000Z",
"cells": [{ "moduleId": "dmod_...", "status": "completed", "watchedPct": 100 }] }
]
}
Alongside those it returns funnel (per module, in course order: reached, completed and
dropFromPrevious), completionsByWeek (the last eight weeks), neverStarted, and
medianTimeToCompleteSec with the timeToCompleteSample it is drawn from. The median is
deliberate: one learner who opens a course in March and finishes in September would make a mean
meaningless.
reached counts anyone who opened a module. Unless the course unlocks in order it is not a strict
funnel — learners can take modules in any order, so dropFromPrevious is a signal worth chasing,
not a proof.
people is a union of everyone with progress, everyone on invitedEmails, and - when
allowRoster is on - the training roster. Somebody who was invited and never pressed play appears
with source: "invited" and every cell not_started, because "who has not done this yet" is the
question the endpoint exists to answer.
A learner who identified with a verified email also joins the workspace training roster, so they show up in the training planner alongside quiz trainings. Finishing a course does not create a qualification - that stays a trainer sign-off.
tracking: false means the course records nothing; people will be empty.
Update and delete
PATCH /v1/courses/:id changes title, userInstructions, projectId (the folder its videos
are filed under), pace and aiVideo (used the next time each module is generated), and the
settings described above. Modules and status are owned by the pipeline
and can't be set by hand - editing them would desync a running job.
DELETE /v1/courses/:id removes the course plan. Videos already generated from its modules are
kept - they're ordinary videos with their own lifecycle. The response tells you how many:
{ "success": true, "id": "dvp_...", "deleted": true, "generatedVideosKept": 2 }
Errors
| Status | Code | Cause |
|---|---|---|
| 400 | validation_error | Missing or conflicting source, or a bad moduleId |
| 401 | authentication_error | Invalid or missing API key |
| 403 | insufficient_scope | Token lacks courses:read / courses:write |
| 403 | limit_exceeded | Not enough credits, or aiVideo on a plan without it |
| 404 | not_found | Course or module not found, or it belongs to another workspace |
| 409 | conflict | A step is already running for that module |