Skip to main content

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/:id includes the answers.
  • GET /v1/modules/:id?forLearner=true returns the same module without them, and sets answersIncluded: false.
  • GET /v1/modules never 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 score
  • GET /v1/modules/:id/passers - distinct people with a passing attempt. Grouped by email, so anonymous attempts don't appear
  • GET /v1/modules/:id/qualifications - who a trainer has signed off. Some modules require that on top of a passing score
  • POST /v1/modules/:id/qualifications with {"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.

FieldTypeDescription
moduleId, moduleTitlestringThe training
email, namestringThe person
statusstringSee below
attemptsnumberAttempts of any kind, passing or not
qualifiedAtstring | nullWhen the current qualification was earned
expiresAtstring | nullWhen it lapses, if the training has a validity window
dueAtstring | nullThe deadline, if one was set
daysToDuenumber | nullDays until it. Negative once the deadline has passed
mandatorybooleanThe training is compulsory for everyone
keywordsstring[]Tags on the training
applicabilitystringundecided, applicable or not_applicable — see below
notApplicablebooleanDeprecated. applicability === "not_applicable"
archivedbooleanThe training itself is retired. Only present when includeArchived=true

Statuses:

StatusMeaning
passedCurrently qualified, with no gap in the record
renewedQualified, but it lapsed at least once before being earned again
expiredHeld a qualification that lapsed and was not re-earned
awaiting_signoffPassed the quiz, waiting on a trainer for modules that require one
in_progressAttempted and not passed
not_startedNever 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:

ValueReturns
publishedTrainings whose article is live in the Knowledge Base
approvedTrainings whose package was approved
allEverything, 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:

applicabilityMeaning
undecidedNobody has ruled this combination in or out. Every cell starts here
applicableSomebody deliberately decided it has to be trained
not_applicableSomebody 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" \
-d '{"moduleId": "mod_123", "email": "[email protected]", "applicability": "not_applicable"}'

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:

FieldWhat it decides
keywordsHow the training is filed and found
settings.mandatoryWhether everyone has to complete it
settings.availableFrom / settings.availableUntilWhen it is open
settings.qualificationValidDaysHow long a pass stays valid
settings.requiresTrainerApprovalWhether a trainer signs each person off
settings.recordAttemptsWhether 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

StatusCodeCause
403limit_exceededTraining isn't enabled for this workspace
404not_foundNo such module in this workspace
409conflictThe module has no questions to answer
423lockedThe module is locked