Viewing Progress Query
Note: The Teachify Admin API is currently under development and not yet available for public use. This documentation is provided for preview purposes only.
Overview
Section titled “Overview”The Viewing Progress API returns per-attachment (video/audio) watch progress across your whole school. Unlike Student Course Progress, which is course-level completion, this feed is fine-grained watch activity and is designed to be polled incrementally: on each run you fetch only the records that changed since your last sync, so you can mirror viewing activity into your own system or count active learners over a time window.
Each record is one row per student + attachment. A student watching three videos produces three records.
Available Queries
Section titled “Available Queries”viewingProgress
Section titled “viewingProgress”Returns a paginated list of watch-progress records for your school, ordered by lastWatchedAt descending (with id as a stable tie-breaker).
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
filter | ViewingProgressFilter | No | Filter criteria (see Filtering) |
page | Int | No | Page number for pagination |
perPage | Int | No | Number of items per page (default: 20, max: 50) |
Scope: Requires the students:read scope.
Returns:
- ViewingProgressPage object with:
nodes: Array of ViewingProgress objectscurrentPage: Current page numberhasNextPage: Whether there are more pageshasPreviousPage: Whether there are previous pagesnodesCount: Total number of matching rows (see the note under Counting active learners)totalPages: Total number of pages
Example:
query { viewingProgress( filter: { lastWatchedAt: { gte: 1704067200 } } page: 1 perPage: 50 ) { nodes { id user { id name email } lectureId attachmentId lastPosition duration progressPercent completed lastWatchedAt } currentPage hasNextPage totalPages nodesCount }}ViewingProgress Object
Section titled “ViewingProgress Object”The ViewingProgress object contains the following fields:
| Field | Type | Description |
|---|---|---|
id | String! | Unique identifier for the watch-progress record. Use it to dedupe across pages |
user | User! | The student user object |
lectureId | String | Lecture ID (may be null for legacy rows) |
attachmentId | String! | Attachment (video/audio) ID |
lastPosition | Float! | Last playback position, in seconds |
duration | Float! | Media duration, in seconds. May be 0 for legacy media with no stored length — prefer progressPercent over lastPosition / duration |
progressPercent | Float! | Watch progress ratio (0.0-1.0) |
completed | Boolean! | Whether the player reported this media watched to completion. Latched: once true it stays true, independent of progressPercent |
lastWatchedAt | Int! | Unix timestamp (epoch seconds). Bumped on every write (position/progress/completion) — use it as the incremental cursor |
User Object in Progress
Section titled “User Object in Progress”Each record includes user information:
| Field | Type | Description |
|---|---|---|
id | String! | User’s unique identifier |
name | String | User’s full name |
email | String | User’s email address |
Filtering
Section titled “Filtering”The viewingProgress query accepts a filter parameter of type ViewingProgressFilter.
| Filter Field | Type | Description |
|---|---|---|
lastWatchedAt | IntOperator | Filter by last watched time (Unix timestamp, epoch seconds). This is the incremental watermark |
userId | StringOperator | Filter by specific user ID(s). in batch is limited to 100 items |
completed | Boolean | Filter by completion state (true / false) |
IntOperator
Section titled “IntOperator”The IntOperator used for numeric filters has these operations:
| Operation | Description |
|---|---|
eq | Equal to |
neq | Not equal to |
gt | Greater than |
gte | Greater than or equal to |
lt | Less than |
lte | Less than or equal to |
StringOperator
Section titled “StringOperator”The StringOperator used for userId has these operations:
| Operation | Description |
|---|---|
eq | Equal to |
in | In a list of values (batch query, max 100 items) |
neq | Not equal to |
nin | Not in a list of values |
Incremental Sync
Section titled “Incremental Sync”This query is built to be polled on a schedule (e.g. daily). Follow this discipline to avoid silently missing or double-counting records:
- Watermark on
lastWatchedAt, keyed to the START of your previous run. Passfilter: { lastWatchedAt: { gte: <previous_run_started_at> } }— not the maximumlastWatchedAtyou observed. Because rows are re-ordered to the top as students keep watching, keying on the max observed value can skip records that were updated while your run was in progress. Overlapping the window is safe. - Dedupe by
id.lastWatchedAtmoves as students watch, so the same record can appear on more than one page within a run. Deduping byid(last write wins) makes the sync idempotent. lastWatchedAtis the only reliable change cursor. It is updated on every write to a record, including the completion latch. There is noupdatedAtfield on this type.- Treat the feed as eventually consistent, not exact per run. A record whose
lastWatchedAtis bumped while you are paging can be shifted from an unread page into an already-read one, so a single run is not guaranteed to return every changed record. The overlapping watermark in step 1 guarantees any such record is picked up on your next run — completeness converges across runs, with at most one poll interval of latency. Do not assume “one run == a complete snapshot of the window.”
Daily incremental sync example
Section titled “Daily incremental sync example”query { viewingProgress( filter: { lastWatchedAt: { gte: 1704067200 # start time of your previous sync run } } page: 1 perPage: 50 ) { nodes { id # dedupe key across pages (String!) user { id } # the student attachmentId # which video/audio (String!) progressPercent # 0.0-1.0 (Float!) completed # latched completion flag (Boolean!) lastWatchedAt # advance your watermark from run start, not this value (Int!) } hasNextPage # loop until this is false }}Page through until hasNextPage is false, then persist the time you started this run as the next watermark.
Performance: For the paging loop, request
hasNextPageand omitnodesCount/totalPages. When those two fields are absent the server detects the next page with a single lightweight lookahead row; requesting either one forces a fullCOUNTof the matching set on every page, which is expensive on large schools. Only ask fornodesCount/totalPageswhen you actually need a total (e.g. a one-off count), not inside an incremental sync loop.
Limitations
Section titled “Limitations”- No delete signal. This feed only surfaces created and updated records — it never emits a tombstone when a record is removed (e.g. a student is unenrolled, a course is deleted, or data is erased for privacy). A mirror built purely from this feed will retain rows that no longer exist upstream. If you need deletion parity, reconcile periodically against an authoritative source (e.g. re-check enrollment) rather than relying on this query alone.
nodesCountandtotalPagesare volatile mid-run. The underlying set changes as students keep watching, so these totals can shift between page fetches. UsehasNextPage == falseas your loop-termination condition — do not precompute a page count fromtotalPagesand assume it holds for the whole run.
Counting active learners
Section titled “Counting active learners”To count distinct students active in a window, filter by the window and dedupe user.id across all pages:
query { viewingProgress( filter: { lastWatchedAt: { gte: 1704067200 # start of window lte: 1704153600 # end of window } } perPage: 50 ) { nodes { user { id } } nodesCount hasNextPage totalPages }}Important:
nodesCountis the total matching row count (one row per attachment), not the number of distinct students. A student who watched several videos is counted multiple times. To count active learners, page allnodesand dedupe byuser.id.
Filter Examples
Section titled “Filter Examples”Records for specific students
Section titled “Records for specific students”query { viewingProgress( filter: { userId: { in: ["user-1-uuid", "user-2-uuid"] } } ) { nodes { user { id } attachmentId progressPercent completed } }}Only completed viewings in a window
Section titled “Only completed viewings in a window”query { viewingProgress( filter: { lastWatchedAt: { gte: 1704067200 } completed: true } ) { nodes { user { id } attachmentId lastWatchedAt } }}Sorting
Section titled “Sorting”Results are sorted by:
lastWatchedAt(descending) — most recently watched firstid(descending) — a stable tie-breaker so pagination is deterministic when records share alastWatchedAt(e.g. bulk-imported viewing records)
Best Practices
Section titled “Best Practices”- Poll incrementally with
lastWatchedAt: key the watermark to your previous run’s start time and dedupe byid. See Incremental Sync. - Do not treat
nodesCountas an active-learner count: it counts rows, not distinct students. - Prefer
progressPercentoverlastPosition / duration:durationcan be0for legacy media. - Batch queries have limits: keep
userId: { in: [...] }under 100 items. - Omit
nodesCount/totalPageswhile paging: they force a per-pageCOUNT; usehasNextPageto loop. See the Performance note above. - Keep the watermark window tight: pagination walks from the first page, so deep page numbers get progressively slower on large schools. Polling frequently (a narrower
lastWatchedAtwindow per run) keeps the number of pages — and page depth — small.
Error Handling
Section titled “Error Handling”Common error scenarios:
- Batch size exceeded: keep
userIdbatch queries under 100 items, or the request is rejected with an error. - Missing scope: the request requires the
students:readscope.
Related Queries
Section titled “Related Queries”- Student Course Progress - Course-level completion per enrollment
- Users Query - To get user information
- Filtering Guide - General filtering documentation
- Pagination Guide - Pagination best practices