Skip to main content
Grading runs in the background, and results keep changing after they first arrive: a teacher corrects a mark, a copy is re-evaluated, an exam is finalized. This guide builds one worker that picks up every change, for every exam, without missing or double-processing any of them.
Webhooks are on the Roadmap. Until they ship, poll the submissions feed as described here. A worker built this way switches to webhooks easily, because both deliver “this submission changed, go and read it”.

The submissions feed

GET /submissions lists every submission of your institute that changed after updated_since, across all exams, oldest change first.
Response (abridged)
string
required
An ISO 8601 UTC time, for example 2026-10-01T09:00:00Z. Only submissions whose updated_at is later are returned. Required on this endpoint; for the first run, use a time before your first submission.
string
The next_cursor of the previous page, passed back unchanged. An altered or invalid cursor returns 400 invalid_cursor.
integer
default:"50"
Page size, 1 to 200.
string
Optional filter, comma-separated: queued, processing, reading, grading, graded, partially_graded, failed, cancelled.
boolean
Optional filter.
boolean
Optional filter.
Rows are ordered by updated_at, then id, so paging with next_cursor never skips a row. A submission that changes while you page moves to the end and shows up again, which is what you want. Each row is a submission status, not the full result. Read the result separately when you need marks.

What moves a submission in the feed

A submission’s updated_at changes, and it appears in the feed again, whenever something you can see about it changes:
  • grading progress: queued to processing, reading, grading, then graded, partially_graded, failed or cancelled
  • a teacher changing marks, on the dashboard or through the API, or approving the copy
  • re-evaluation, finalize and unfinalize
  • the submission being replaced or deleted
The feed includes replaced and deleted submissions, so your copy of the data can follow them. Check state on every row:

The worker

The loop:
  1. Load your checkpoint, the latest updated_at you have fully processed.
  2. Request the feed from a little before the checkpoint, and follow next_cursor until has_more is false.
  3. Process each row idempotently, skipping changes you have already applied.
  4. Save the new checkpoint, then sleep and repeat.
Start each poll a little before your checkpoint. A change that was being saved while you polled can carry a timestamp slightly earlier than rows you already received. Starting each poll one to two minutes before the checkpoint, and skipping rows you have already applied, guarantees nothing slips through.
updated_at is UTC with a variable number of fraction digits (2026-10-02T02:17:11.675Z, 2026-10-02T02:17:11.675412Z). Don’t compare the raw strings: parse them, or pad the fraction to a fixed length first, as the examples do. Store the value exactly as received.

Processing idempotently

The same change can reach you more than once: the overlap window replays recent rows, a row that changes while you page through the feed appears again on a later page, and a crashed worker repeats its last batch. Make every write safe to repeat:
  • Key everything by submission id. Upsert, never insert blindly.
  • Remember the updated_at you last applied for each submission, and skip a row that is not newer.
  • Save the checkpoint only after the rows are applied. If the worker dies halfway, the next run starts from the old checkpoint and repeats the work, which is harmless.
  • Link results to your own records with metadata (your answer-sheet or attempt ID, up to 2 KB) or the candidate’s external_id.

How often to poll

Every poll counts towards your rate limit. On the standard tier, one key can make 20 reads per second and 600 per minute. Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers. See Going live.

Backoff and errors

Reading a whole exam at once

When a teacher finishes reviewing, you usually want every result of one exam. GET /exams/{id}/results returns full results (the same shape as GET /submissions/{id}/result) for the exam’s live submissions, so you don’t need one request per student.
  • limit is 1 to 50, default 20. Page with cursor and next_cursor.
  • updated_since returns only results that changed since your last read.
  • finalized=true returns only final marks; finalized=false only drafts.
  • Results are JSON. format=csv returns 422 feature_not_available for now.
GET /exams/{id}/submissions?include=result works too, if you also want filters such as status or needs_review (limit is capped at 50 with include=result).

Next steps

Going live

The checklist before your first real exam.

Roadmap

Webhooks and what else is coming.