Shop It Docs
Developer Resourcescourse

Video Progress & Resume

Heartbeat ingest, watch position persistence, auto-completion, and progress indicator contracts.

Video Progress & Resume

Audience: Mobile/web frontend developers integrating video playback progress. Scope: Heartbeat endpoint, resume data, watch percentage, auto-completion, error handling.


1. Quick Metadata

  • Module: Course Learning (customer)
  • Auth: User JWT (Bearer)
  • Base URL: /api/courses
  • Rate limits: Heartbeat: 1 per 8s per user+lesson. Other endpoints: standard course customer rate limit.
  • Env vars: COMPLETION_THRESHOLD_PERCENT (default 90), HEARTBEAT_RATE_LIMIT_SECONDS (default 8)

2. Overview

The video progress system has four components:

  1. Heartbeat ingest — player sends position every 10-30s during playback
  2. Resume — lesson content endpoint returns last watch position for seek-on-load
  3. Progress indicators — curriculum/progress endpoints return watch percentage per lesson
  4. Auto-completion — lesson is marked complete when watch threshold is reached

3. Sequence Diagram

Player                    API                         DB (course_progress)     Redis
  |                        |                               |                    |
  |-- POST heartbeat ----->|                               |                    |
  |                        |-- check rate limit ---------->|                    |
  |                        |<-- OK / 429 ------------------|                    |
  |                        |-- validate JWT + enrollment ->|                    |
  |                        |-- SELECT progress ----------->|                    |
  |                        |<-- current position ----------|                    |
  |                        |-- if position advanced:       |                    |
  |                        |   UPSERT with GREATEST() ---->|                    |
  |                        |-- if threshold reached:       |                    |
  |                        |   SET isCompleted=true ------->|                    |
  |                        |-- INSERT watch_events -------->|                    |
  |<-- 200 { position,    |                               |                    |
  |     isCompleted,       |                               |                    |
  |     watchPercentage }  |                               |                    |
  |                        |                               |                    |
  |--- GET lesson content->|                               |                    |
  |                        |-- SELECT progress ----------->|                    |
  |<-- { lastWatchPosition,|                               |                    |
  |      watchPercentage } |                               |                    |
  |                        |                               |                    |
  |--- GET curriculum ---->|                               |                    |
  |                        |-- SELECT all progress ------->|                    |
  |<-- { lessons[].        |                               |                    |
  |      watchPercentage } |                               |                    |

4. Endpoints

4.1 Send Heartbeat

POST /courses/:courseId/lessons/:lessonId/heartbeat

FieldValue
AuthBearer JWT
Rate limit1 per 8s per user+lesson
IdempotentYes — no-op if watchPosition has not advanced

Request body:

{
  "watchPosition": 125,
  "totalWatchedSeconds": 120
}
FieldTypeDescription
watchPositioninteger >= 0Current playback position in seconds
totalWatchedSecondsinteger >= 0Cumulative seconds watched (accounts for seeks/rewinds)

Success response (200):

{
  "message": "Heartbeat processed",
  "data": {
    "watchPosition": 125,
    "totalWatchedSeconds": 120,
    "isCompleted": false,
    "watchPercentage": 41.67
  }
}

Auto-completion: When totalWatchedSeconds / videoDuration * 100 >= COMPLETION_THRESHOLD_PERCENT (default 90), the response will return isCompleted: true. This is a one-way latch — once complete, it stays complete.

4.2 Resume Data (Lesson Content)

GET /courses/:courseId/lessons/:lessonId — existing endpoint, enriched.

New fields in response data:

FieldTypeDescription
lastWatchPositionnumber | nullSeconds. Null if never watched. Player should video.currentTime = lastWatchPosition.
watchPercentagenumber | null0-100. Null if no progress or no video duration.

4.3 Progress Indicators (Curriculum)

GET /courses/:courseId/curriculum — existing endpoint, enriched.

New field per lesson in topics[].lessons[]:

FieldTypeDescription
watchPercentagenumber | null0-100. Use for progress bar width. Null if no video or no progress.

4.4 Detailed Progress

GET /courses/:courseId/progress — existing endpoint, enriched.

New field per lesson in topics[].lessons[]:

FieldTypeDescription
watchPercentagenumber | null0-100. Null if no video or no progress.

5. Error Codes

CodeHTTPWhen
HEARTBEAT_TOKEN_EXPIRED401JWT is expired or invalid
HEARTBEAT_NOT_ENROLLED403User does not have active access to this course
HEARTBEAT_LESSON_NOT_FOUND404Lesson does not exist, is disabled, or does not belong to course
RATE_LIMIT_EXCEEDED429More than 1 heartbeat per 8s for same user+lesson

All errors follow the standard ResponseDto envelope:

{
  "message": "You are not enrolled in this course.",
  "errorCode": "HEARTBEAT_NOT_ENROLLED"
}

6. Integration Recipes

6.1 Player Heartbeat Loop

const HEARTBEAT_INTERVAL = 15_000;
let heartbeatTimer: ReturnType<typeof setInterval>;

function startHeartbeat(courseId: number, lessonId: number) {
  heartbeatTimer = setInterval(async () => {
    const position = Math.floor(video.currentTime);
    const watched = getTotalWatchedSeconds();
    await fetch(`/api/courses/${courseId}/lessons/${lessonId}/heartbeat`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ watchPosition: position, totalWatchedSeconds: watched }),
    });
  }, HEARTBEAT_INTERVAL);
}

6.2 Resume on Load

const lesson = await fetch(`/api/courses/${courseId}/lessons/${lessonId}`);
const { lastWatchPosition } = lesson.data;
if (lastWatchPosition !== null) {
  video.currentTime = lastWatchPosition;
}

6.3 Progress Bar

const curriculum = await fetch(`/api/courses/${courseId}/curriculum`);
for (const topic of curriculum.data.topics) {
  for (const lesson of topic.lessons) {
    renderProgressBar(lesson.id, lesson.watchPercentage ?? 0);
  }
}

7. Monitoring & Observability

MetricSourceAlert threshold
Heartbeat p95 latencyApplication logs> 200ms
Rate limit rejections429 response count> 10% of heartbeat traffic
Auto-completion ratewatch_events + course_progressInformational
Heartbeat error rate4xx responses / total> 5%

8. Data Model

course_progress (extended)

ColumnTypeDescription
watch_positionintegerLast known playback position (seconds). Default 0.
total_watched_secondsintegerCumulative watch time (seconds). Default 0.
(existing) is_completedbooleanNow auto-set by threshold in addition to manual completion.
(existing) completed_attimestamptzSet on first completion (COALESCE preserves original).

watch_events (new)

ColumnTypeDescription
idserialPrimary key
user_iduuidFK to customers
lesson_idintegerFK to course_lessons
event_typevarchar(20)heartbeat, play, pause, seek, ended
watch_positionintegerPosition at event time (seconds)
total_watched_secondsintegerCumulative watch time at event time
created_attimestamptzServer-side timestamp

Indexes: (user_id, lesson_id), (created_at) for TTL cleanup.