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:
- Heartbeat ingest — player sends position every 10-30s during playback
- Resume — lesson content endpoint returns last watch position for seek-on-load
- Progress indicators — curriculum/progress endpoints return watch percentage per lesson
- 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
| Field | Value |
|---|---|
| Auth | Bearer JWT |
| Rate limit | 1 per 8s per user+lesson |
| Idempotent | Yes — no-op if watchPosition has not advanced |
Request body:
{
"watchPosition": 125,
"totalWatchedSeconds": 120
}| Field | Type | Description |
|---|---|---|
watchPosition | integer >= 0 | Current playback position in seconds |
totalWatchedSeconds | integer >= 0 | Cumulative 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:
| Field | Type | Description |
|---|---|---|
lastWatchPosition | number | null | Seconds. Null if never watched. Player should video.currentTime = lastWatchPosition. |
watchPercentage | number | null | 0-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[]:
| Field | Type | Description |
|---|---|---|
watchPercentage | number | null | 0-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[]:
| Field | Type | Description |
|---|---|---|
watchPercentage | number | null | 0-100. Null if no video or no progress. |
5. Error Codes
| Code | HTTP | When |
|---|---|---|
HEARTBEAT_TOKEN_EXPIRED | 401 | JWT is expired or invalid |
HEARTBEAT_NOT_ENROLLED | 403 | User does not have active access to this course |
HEARTBEAT_LESSON_NOT_FOUND | 404 | Lesson does not exist, is disabled, or does not belong to course |
RATE_LIMIT_EXCEEDED | 429 | More 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
| Metric | Source | Alert threshold |
|---|---|---|
| Heartbeat p95 latency | Application logs | > 200ms |
| Rate limit rejections | 429 response count | > 10% of heartbeat traffic |
| Auto-completion rate | watch_events + course_progress | Informational |
| Heartbeat error rate | 4xx responses / total | > 5% |
8. Data Model
course_progress (extended)
| Column | Type | Description |
|---|---|---|
watch_position | integer | Last known playback position (seconds). Default 0. |
total_watched_seconds | integer | Cumulative watch time (seconds). Default 0. |
(existing) is_completed | boolean | Now auto-set by threshold in addition to manual completion. |
(existing) completed_at | timestamptz | Set on first completion (COALESCE preserves original). |
watch_events (new)
| Column | Type | Description |
|---|---|---|
id | serial | Primary key |
user_id | uuid | FK to customers |
lesson_id | integer | FK to course_lessons |
event_type | varchar(20) | heartbeat, play, pause, seek, ended |
watch_position | integer | Position at event time (seconds) |
total_watched_seconds | integer | Cumulative watch time at event time |
created_at | timestamptz | Server-side timestamp |
Indexes: (user_id, lesson_id), (created_at) for TTL cleanup.