Skip to content

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.

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.

Returns a paginated list of watch-progress records for your school, ordered by lastWatchedAt descending (with id as a stable tie-breaker).

Parameters:

ParameterTypeRequiredDescription
filterViewingProgressFilterNoFilter criteria (see Filtering)
pageIntNoPage number for pagination
perPageIntNoNumber of items per page (default: 20, max: 50)

Scope: Requires the students:read scope.

Returns:

  • ViewingProgressPage object with:
    • nodes: Array of ViewingProgress objects
    • currentPage: Current page number
    • hasNextPage: Whether there are more pages
    • hasPreviousPage: Whether there are previous pages
    • nodesCount: 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
}
}

The ViewingProgress object contains the following fields:

FieldTypeDescription
idString!Unique identifier for the watch-progress record. Use it to dedupe across pages
userUser!The student user object
lectureIdStringLecture ID (may be null for legacy rows)
attachmentIdString!Attachment (video/audio) ID
lastPositionFloat!Last playback position, in seconds
durationFloat!Media duration, in seconds. May be 0 for legacy media with no stored length — prefer progressPercent over lastPosition / duration
progressPercentFloat!Watch progress ratio (0.0-1.0)
completedBoolean!Whether the player reported this media watched to completion. Latched: once true it stays true, independent of progressPercent
lastWatchedAtInt!Unix timestamp (epoch seconds). Bumped on every write (position/progress/completion) — use it as the incremental cursor

Each record includes user information:

FieldTypeDescription
idString!User’s unique identifier
nameStringUser’s full name
emailStringUser’s email address

The viewingProgress query accepts a filter parameter of type ViewingProgressFilter.

Filter FieldTypeDescription
lastWatchedAtIntOperatorFilter by last watched time (Unix timestamp, epoch seconds). This is the incremental watermark
userIdStringOperatorFilter by specific user ID(s). in batch is limited to 100 items
completedBooleanFilter by completion state (true / false)

The IntOperator used for numeric filters has these operations:

OperationDescription
eqEqual to
neqNot equal to
gtGreater than
gteGreater than or equal to
ltLess than
lteLess than or equal to

The StringOperator used for userId has these operations:

OperationDescription
eqEqual to
inIn a list of values (batch query, max 100 items)
neqNot equal to
ninNot in a list of values

This query is built to be polled on a schedule (e.g. daily). Follow this discipline to avoid silently missing or double-counting records:

  1. Watermark on lastWatchedAt, keyed to the START of your previous run. Pass filter: { lastWatchedAt: { gte: <previous_run_started_at> } } — not the maximum lastWatchedAt you 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.
  2. Dedupe by id. lastWatchedAt moves as students watch, so the same record can appear on more than one page within a run. Deduping by id (last write wins) makes the sync idempotent.
  3. lastWatchedAt is the only reliable change cursor. It is updated on every write to a record, including the completion latch. There is no updatedAt field on this type.
  4. Treat the feed as eventually consistent, not exact per run. A record whose lastWatchedAt is 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.”
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 hasNextPage and omit nodesCount / totalPages. When those two fields are absent the server detects the next page with a single lightweight lookahead row; requesting either one forces a full COUNT of the matching set on every page, which is expensive on large schools. Only ask for nodesCount / totalPages when you actually need a total (e.g. a one-off count), not inside an incremental sync loop.

  • 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.
  • nodesCount and totalPages are volatile mid-run. The underlying set changes as students keep watching, so these totals can shift between page fetches. Use hasNextPage == false as your loop-termination condition — do not precompute a page count from totalPages and assume it holds for the whole run.

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: nodesCount is 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 all nodes and dedupe by user.id.

query {
viewingProgress(
filter: {
userId: { in: ["user-1-uuid", "user-2-uuid"] }
}
) {
nodes {
user { id }
attachmentId
progressPercent
completed
}
}
}
query {
viewingProgress(
filter: {
lastWatchedAt: { gte: 1704067200 }
completed: true
}
) {
nodes {
user { id }
attachmentId
lastWatchedAt
}
}
}

Results are sorted by:

  1. lastWatchedAt (descending) — most recently watched first
  2. id (descending) — a stable tie-breaker so pagination is deterministic when records share a lastWatchedAt (e.g. bulk-imported viewing records)
  1. Poll incrementally with lastWatchedAt: key the watermark to your previous run’s start time and dedupe by id. See Incremental Sync.
  2. Do not treat nodesCount as an active-learner count: it counts rows, not distinct students.
  3. Prefer progressPercent over lastPosition / duration: duration can be 0 for legacy media.
  4. Batch queries have limits: keep userId: { in: [...] } under 100 items.
  5. Omit nodesCount / totalPages while paging: they force a per-page COUNT; use hasNextPage to loop. See the Performance note above.
  6. 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 lastWatchedAt window per run) keeps the number of pages — and page depth — small.

Common error scenarios:

  • Batch size exceeded: keep userId batch queries under 100 items, or the request is rejected with an error.
  • Missing scope: the request requires the students:read scope.