Training Modules
A quiz attached to a video or a knowledge-base article, plus the records of who took it, who passed, and who a trainer has signed off. The second half is the point: it's what an HR or LMS system actually wants to sync.
Training is a paid capability. Without it, generating returns 403.
Answer keys
A module carries its answer keys - correctIndex and each question's explanation. That's right for
your own tooling and wrong for anything a learner can see.
GET /v1/modules/:idincludes the answers.GET /v1/modules/:id?forLearner=truereturns the same module without them, and setsanswersIncluded: false.GET /v1/modulesnever includes them.
Grading always happens server-side, so a learner-facing client never needs the keys.
Generate
POST /v1/modules/generate
curl -X POST https://api.vidocu.ai/v1/modules/generate \
-H "Authorization: Bearer vdo_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"videoId": "vid_abc123", "questionCount": 8, "difficulty": "medium"}'
Exactly one of videoId or articleId. Questions are written from the video's help article and
transcript, or the article's content.
If that source already has a module, this replaces its questions rather than creating a second one - the same behaviour as the dashboard. Generation from a video is bundled into that video's credit cost and isn't charged separately.
Read, edit, lock
GET /v1/modules · GET /v1/modules/:id · PATCH /v1/modules/:id ·
DELETE /v1/modules/:id · POST /v1/modules/:id/lock
A locked module is frozen outright: every edit returns
423 and it can't be deleted. Unlike a locked video, there is no
filing-field exception. Locking also happens automatically when the video that owns the module is
locked.
There is no unlock endpoint, matching the app.
Translation
POST /v1/modules/:id/translate with {"targetLocales": ["de", "fr"]}
GET /v1/modules/:id/translations lists them. Each carries stale - set when the module's
content changed after that translation was made. Re-translate before relying on a stale one.
Recording attempts
POST /v1/modules/:id/submit
This is the LMS integration point. Send the answers a person gave; grading happens here against the answer keys, so the caller can't influence the outcome.
curl -X POST https://api.vidocu.ai/v1/modules/mod_123/submit \
-H "Authorization: Bearer vdo_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"name": "Dana Levi",
"answers": [{"questionId": "q1", "selectedIndex": 2}]
}'
{
"score": 80,
"passed": true,
"total": 5,
"passingScore": 70,
"results": [{ "questionId": "q1", "correctIndex": 2, "wasCorrect": true }]
}
Attempts recorded this way are stored with surface: "api", so they're distinguishable from ones
taken on a share page or in the Knowledge Center. If the module has recordAttempts off, the
result is still graded and returned but nothing is stored.
Who passed, who is qualified
These are not the same thing, and compliance usually cares about the second.
GET /v1/modules/:id/results- every attempt, with pass rate and average scoreGET /v1/modules/:id/passers- distinct people with a passing attempt. Grouped by email, so anonymous attempts don't appearGET /v1/modules/:id/qualifications- who a trainer has signed off. Some modules require that on top of a passing scorePOST /v1/modules/:id/qualificationswith{"email": "...", "name": "..."}records a sign-off, attributed to the user behind the credential. Calling it twice returns the existing record rather than duplicating
Analytics
GET /v1/modules/analytics - per module: attempts, passes, pass rate, last attempt.
GET /v1/modules/analytics/by-person - per person: attempts, passes, how many modules they've
passed.
GET /v1/modules/analytics/signoffs - recent trainer sign-offs across the workspace.
The planner
Every analytics endpoint above is built from submissions, which means they can only describe people who have already attempted something. The question a quality system actually asks is the opposite one: who has not done this training yet?
The planner answers it by treating training as a grid — every training against every person — rather than a list of records. A cell exists because a training and a person both exist, so somebody who has never taken anything still appears, against everything, as not_started.
The roster
The people axis comes from a roster plus everyone already discovered in training records.
GET /v1/modules/people returns both: people is the roster, discovered is anyone with training records who is not on it yet.
POST /v1/modules/people with {"email": "...", "name": "..."} adds somebody before they have trained at all. The email must be the one they will train under, because it is how every training record is matched. Idempotent: calling it again updates the name rather than creating a second entry.
The grid
GET /v1/modules/planner returns one row per (training, person), most urgent first.
| Field | Type | Description |
|---|---|---|
moduleId, moduleTitle | string | The training |
email, name | string | The person |
status | string | See below |
attempts | number | Attempts of any kind, passing or not |
qualifiedAt | string | null | When the current qualification was earned |
expiresAt | string | null | When it lapses, if the training has a validity window |
dueAt | string | null | The deadline, if one was set |
daysToDue | number | null | Days until it. Negative once the deadline has passed |
mandatory | boolean | The training is compulsory for everyone |
keywords | string[] | Tags on the training |
applicability | string | undecided, applicable or not_applicable — see below |
notApplicable | boolean | Deprecated. applicability === "not_applicable" |
archived | boolean | The training itself is retired. Only present when includeArchived=true |
Statuses:
| Status | Meaning |
|---|---|
passed | Currently qualified, with no gap in the record |
renewed | Qualified, but it lapsed at least once before being earned again |
expired | Held a qualification that lapsed and was not re-earned |
awaiting_signoff | Passed the quiz, waiting on a trainer for modules that require one |
in_progress | Attempted and not passed |
not_started | Never attempted |
renewed is the one worth reading carefully. It separates a clean record from one with a documented gap, which is what a periodic re-training regime cares about. Renewing early, before the old qualification expired, is not a gap and stays passed.
Filters: moduleId, email, status, search (matches a person or a training title), mandatory=true for trainings marked compulsory for everyone, applicability for what somebody decided about the combination, and overdue=true for rows whose deadline has passed and who are still not qualified. Somebody who finished after their deadline is done, not overdue.
Which trainings belong in a plan
A training exists from the moment it is defined, so an unfiltered planner is full of work in progress. readiness limits it to content that is finished:
| Value | Returns |
|---|---|
published | Trainings whose article is live in the Knowledge Base |
approved | Trainings whose package was approved |
all | Everything, including drafts |
Omit it and the server chooses the narrowest option that actually has trainings behind it, preferring published, then approved, then all. Preferring published unconditionally would open an empty planner for a workspace with no Knowledge Base. The choice is reported back as readiness on the response, alongside readinessCounts for all three, so a client can show what was applied instead of guessing.
Archived trainings are out of the planner by default, whatever readiness says: archived means retired, and a plan is about what still has to happen. Pass includeArchived=true to bring them back, flagged archived: true on every row, because a training somebody completed before it was withdrawn is still a record they may have to produce. The response carries archivedCount either way, so a client can tell whether the option has anything behind it.
Keywords
keyword returns only trainings carrying that tag, matched whole and case-insensitively. The response carries keywords: every keyword in use in the workspace, which is the whole of the "catalogue" — a memory of what has been typed, with no taxonomy to maintain.
Ask for several by repeating the parameter or sending one comma-separated list:
?keyword=Safety&keyword=Annual
?keywords=Safety,Annual
Several keywords are an OR: a training carrying any of them is returned. Narrowing to trainings that carry both tags is a different question, and not one this filter answers. Blanks are dropped, duplicates collapse case-insensitively, and at most 20 are read.
Set them with PATCH /v1/modules/:id and {"keywords": ["Safety", "Annual"]}. Up to 20 per training, 40 characters each, de-duplicated case-insensitively. Keywords and the planning settings are editable on a locked module — see Planning metadata and locks.
Cells marked not applicable are excluded by default; pass includeNa=true to see them, or filter on applicability directly. They never count as overdue, whatever date they carry.
Sorting: sort accepts urgency (the default — furthest overdue first, then soonest due, then everything with no deadline), training, person, status, expires or deadline, with dir=asc|desc. Rows with no value for the sorted column always sort last, in both directions: a missing expiry date is unknown, not earliest.
The response also carries unattributedAttempts: attempts recorded without an email, which cannot be matched to a person and so are not on the grid. Anonymous share-link attempts land here.
Deadlines
PUT /v1/modules/planner/deadline with {"moduleId": "...", "email": "...", "dueAt": "2026-09-30"} sets the date by which that person must complete that training. Pass "dueAt": null to clear it.
A deadline is an expectation rather than a record of anything that happened, so it can be set on somebody who has never attempted the training. That is the point of it.
Which combinations have to be trained
The grid is a full cross-product, and some of its cells describe work nobody will ever do — a machine a person will never operate. Every cell carries one of three decisions:
applicability | Meaning |
|---|---|
undecided | Nobody has ruled this combination in or out. Every cell starts here |
applicable | Somebody deliberately decided it has to be trained |
not_applicable | Somebody deliberately decided it never applies |
undecided and applicable look identical on the grid, and that is exactly why they are separate: "we decided this person needs it" and "nobody has looked at this yet" are different facts in an audit, and a single checkbox reported both as a blank box.
PUT /v1/modules/planner/deadline with {"moduleId": "...", "email": "...", "applicability": "not_applicable"} rules a combination out. It disappears from the grid unless the caller asks for includeNa=true or filters on applicability=not_applicable, and it stops counting as overdue. Send applicable to rule it in, or undecided to erase the decision: "not relevant" turns into "required" the day somebody changes department.
The older notApplicable boolean still works — true is not_applicable, false is undecided — and is still returned on every row. applicability wins when both are sent.
dueAt and the decision are independent. Send either one and the other is left as it was; send neither and the request is rejected.
# Who is overdue, across every training
curl "https://api.vidocu.ai/v1/modules/planner?overdue=true" \
-H "Authorization: Bearer vdo_live_your_key_here"
# Who has never started the fire-safety training
curl "https://api.vidocu.ai/v1/modules/planner?moduleId=mod_123&status=not_started" \
-H "Authorization: Bearer vdo_live_your_key_here"
# Everything published, tagged Safety, that somebody has not completed
curl "https://api.vidocu.ai/v1/modules/planner?readiness=published&keyword=Safety&status=not_started" \
-H "Authorization: Bearer vdo_live_your_key_here"
# Every mandatory training somebody has not completed, soonest deadline first
curl "https://api.vidocu.ai/v1/modules/planner?mandatory=true&status=not_started&sort=deadline" \
-H "Authorization: Bearer vdo_live_your_key_here"
# Combinations nobody has ruled in or out yet
curl "https://api.vidocu.ai/v1/modules/planner?applicability=undecided" \
-H "Authorization: Bearer vdo_live_your_key_here"
# Who completed a training that has since been retired
curl "https://api.vidocu.ai/v1/modules/planner?includeArchived=true&status=passed" \
-H "Authorization: Bearer vdo_live_your_key_here"
# This training will never apply to this person
curl -X PUT "https://api.vidocu.ai/v1/modules/planner/deadline" \
-H "Authorization: Bearer vdo_live_your_key_here" \
-H "Content-Type: application/json" \
Planning metadata and locks
A locked module is frozen: PATCH /v1/modules/:id returns 423 for any change to its title, description, questions, state or settings.
The planning fields are the exception:
| Field | What it decides |
|---|---|
keywords | How the training is filed and found |
settings.mandatory | Whether everyone has to complete it |
settings.availableFrom / settings.availableUntil | When it is open |
settings.qualificationValidDays | How long a pass stays valid |
settings.requiresTrainerApproval | Whether a trainer signs each person off |
settings.recordAttempts | Whether attempts are recorded from now on |
A lock exists so the evidence cannot change. Who has to take a training, and by when, is not evidence — and a workspace that locks its packages on approval, which is the point of locking, would otherwise never be able to mark an existing training mandatory, retag it, or move when it closes.
settings.participantIdentity is not in the list, and stays frozen. It decides what a taker is asked for before they start, and therefore whether a result can be attributed to a person at all. Moving it after people have taken the training leaves one training whose records are half anonymous and half named, with no way to tell from a result which rule was in force. That is evidence.
The carve-out is all-or-nothing per request: a body that mixes keywords with a title change is rejected whole, so nothing rides in alongside a permitted field.
Because these fields can move after an approval, two things happen on every change.
It is recorded — who made it, when, and whether the module was locked at the time. The record is visible in the app, on a training's Change history tab. It covers the planning fields, not content, which a lock prevents from changing at all.
The approvers are emailed. Anyone who approved the package the training belongs to is told what changed, so a signature is never quietly amended underneath them. This fires for changes made through the API too, the editor excepted: nobody is mailed about their own edit.
# Allowed on a locked module
curl -X PATCH "https://api.vidocu.ai/v1/modules/mod_123" \
-H "Authorization: Bearer vdo_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"keywords": ["Safety", "Annual"], "settings": {"mandatory": true, "qualificationValidDays": 365}}'
# 423 — participantIdentity is evidence, not planning
curl -X PATCH "https://api.vidocu.ai/v1/modules/mod_123" \
-H "Authorization: Bearer vdo_live_your_key_here" \
-d '{"settings": {"participantIdentity": "anonymous"}}'
# 423 — the title is content
curl -X PATCH "https://api.vidocu.ai/v1/modules/mod_123" \
-H "Authorization: Bearer vdo_live_your_key_here" \
-d '{"keywords": ["Safety"], "title": "New name"}'
Errors
| Status | Code | Cause |
|---|---|---|
| 403 | limit_exceeded | Training isn't enabled for this workspace |
| 404 | not_found | No such module in this workspace |
| 409 | conflict | The module has no questions to answer |
| 423 | locked | The module is locked |