DocsAPI Reference

Captions API

WebVTT caption tracks for lessons, plus parsed transcript

OptiLearn lessons can carry one or more WebVTT (.vtt) caption tracks per video. Tracks back the native HTML5 <track> element and a separate transcript panel that lets students click cues to seek the player.

Storage: caption files live in R2 under lms/<institutionId>/courses/<courseId>/lessons/<lessonId>/captions/<file>.

Authoring is gated by canManageCourses(user). Reads are open to anyone who can see the lesson — the lesson route itself enforces enrollment.

GET /api/lessons/[id]/captions

List every CaptionTrack on a lesson. Ordered with the default track first so <track default> lands on the right element.

Response

{
  "data": [
    {
      "id": "ct_...",
      "lessonId": "...",
      "blockId": null,
      "language": "en",
      "label": "English",
      "vttUrl": "https://files.opticrm.app/lms/.../captions/lecture.vtt",
      "isDefault": true,
      "createdAt": "..."
    }
  ]
}

blockId distinguishes tracks when a lesson has multiple VideoBlock blocks (Content v2). Null = lesson-level video.

POST /api/lessons/[id]/captions

Multipart upload of a .vtt file. multipart/form-data body:

FieldRequiredNotes
fileyesThe .vtt file (max 1 MB)
languageyesISO 639-1 code, must be in supported list
labelyesHuman-readable, 1–80 chars
blockIdnoWhen the lesson has multiple video blocks
isDefaultnotrue / 1 / on to win the <track default> slot

Validation

  • File must be text/vtt, text/plain, empty mime + .vtt extension, or have a .vtt extension (some browsers report empty mime for VTT)
  • File contents must start with the WEBVTT magic header
  • language must be in SUPPORTED_CAPTION_LANGUAGES
  • File size capped at 1 MB

Side effects

  1. Uploaded to R2 server-side (small text payload, no presigned PUT needed)
  2. If isDefault: true, any existing default track on the same (lesson, blockId) is demoted
  3. New CaptionTrack row written

Response: the created track row, status 201.

curl -X POST /api/lessons/lsn_123/captions \
  -F "file=@english.vtt" \
  -F "language=en" \
  -F "label=English" \
  -F "isDefault=true"

PATCH /api/lessons/[id]/captions/[captionId]

Edit label, language, or isDefault. Body:

{
  "label": "English (UK)",
  "language": "en",
  "isDefault": true
}

All fields optional. Toggling isDefault: true demotes other defaults on the same (lesson, blockId) scope.

Response: the updated row.

DELETE /api/lessons/[id]/captions/[captionId]

Removes the row + the underlying R2 object. R2 cleanup is best-effort — if the bucket delete fails (object already gone, transient error), the row is still dropped so the UI doesn't get stuck.

Response: { data: { id } }.

GET /api/lessons/[id]/captions/[captionId]/transcript

Fetches the .vtt file from R2, parses it into a JSON cue array, and returns it. Backs the transcript panel — clicking a cue seeks the player.

Response

{
  "data": {
    "captionId": "ct_...",
    "language": "en",
    "label": "English",
    "cues": [
      { "start": 0.0, "end": 4.2, "text": "Welcome to today's lecture." },
      { "start": 4.2, "end": 9.1, "text": "We'll cover three main ideas..." }
    ]
  }
}

Caching

  • revalidate = 3600 (1 hour) — VTT files are immutable post-upload (re-uploading creates a new track + R2 key)
  • Response carries Cache-Control: private, max-age=3600 so any in-front CDN that varies on Cookie can still share entries underneath

Errors

StatusCodeReason
404NOT_FOUNDCaption track doesn't exist
502INTERNAL_ERRORR2 fetch failed