Skip to main content
A submission is one candidate’s answers to one exam: either a handwritten copy (a PDF upload) or typed answers. Creating a submission fixes its price, reserves the credits and puts it in the grading queue. When grading finishes, read its result.
The exam must be open before it accepts submissions (POST /exams/{exam_id}/open), and its mode decides the body: a handwritten exam takes upload_id, a typed exam takes answers[]. Sending the wrong one is refused with 422 mode_mismatch. See Exams.
The Python and Node samples on this page assume this setup. The Node samples use top-level await, so run them as ES modules (a .mjs file, or "type": "module" in package.json).

Identify the candidate

Every submission names its candidate in one of two ways. Send exactly one of them.
object
Your own reference for the candidate. The candidate is created or updated by external_id and registered for the exam automatically if needed. Fields you send replace stored ones; fields you leave out keep their stored value.
string
The Evalezy id of a candidate you already created.
Each candidate can have one live submission per exam. A second submission for the same candidate and exam is refused with 409 submission_exists, and details.submission_id gives you the existing one. To send a new copy, replace it.
object
Optional. Any JSON object up to 2 KB, such as your barcode or booklet number. It is returned on the submission as is.

Handwritten copies

Send the upload_id of a PDF you uploaded. The upload must belong to your institute, hold a readable PDF and not be used by another submission.
The response is 202 Accepted: the submission object plus the quote and any warnings.

Page policy

Handwritten copies are priced and handled by their page count, which comes from the upload.
Every page of the PDF counts, including blank ones. Remove cover sheets and blank pages before you upload to keep the cost down.
Handwriting in Hindi or other regional languages is not supported yet. A copy written mostly in Devanagari fails with the error code language_not_supported and is not charged.

Typed answers

For a typed exam, send answers[]. Each answer names its question by question_id or by question_label (labels are matched without regard to case) and uses the field that fits the question type. Option labels are the labels you gave the options when you created the exam.
Rules for typed answers:
  • Send at most one answer per question. Questions you leave out, and answers that are empty, count as not answered: they score 0 and are not charged.
  • Objective answers (mcq_*, true_false, numeric, one_word) are marked automatically against the answer key, free of charge.
  • Each non-blank long_answer costs 1 credit at the standard rate. A submission with no non-blank long answer is marked at once with status: "graded" and a quote of 0 credits.
  • Send plain text. Markup and angle brackets are kept as plain text, so x < 5 reaches the grader as typed. Text containing NUL characters (\u0000) is refused with 422 validation_failed, with the field error code invalid_characters.
  • An answer whose letters are more than 20% Devanagari is refused with 422 language_not_supported (details.question_label). Only English answers are graded today.
  • An option label the question does not have is refused with 422 unknown_option_label.

Quotes and credits

Every submission is priced before grading starts, and the price does not change afterwards.
object
The credits are reserved when the submission is accepted and charged only when grading completes. Failed and cancelled submissions are not charged, and credits_charged on the submission shows what was actually charged. If your balance cannot cover the quote, the submission is refused with 402 insufficient_credits, and details gives required, available, balance, credit_limit and committed. Buy credits in the Vacademy dashboard; see evalezy.com/pricing.

The submission object

Every submission endpoint (create, get, the lists and the feed) returns this same shape.
string
The submission id.
string
The exam it belongs to.
object
{ "id", "external_id" } of the candidate.
string
live, replaced or deleted. Only a live submission can be reviewed, finalized or changed.
string
Present only when state is replaced: the id of the submission that replaced it.
string
Where grading is. See Statuses.
string
copy for handwritten copies, typed for typed answers.
integer | null
Pages of the handwritten copy; null for typed answers.
boolean
true when a teacher should look at the result. See Needs review.
string[]
Submission-level reasons, such as pages_beyond_vision_limit. Cleared when the submission is approved.
boolean
Whether the result is final. See Finalize.
string | null
When it was finalized.
object | null
{ "step", "questions_done", "questions_total" }, plus estimated_ready_at while grading runs. null when no AI grading was needed. step is informational; do not branch on it.
object | null
{ "position", "estimated_ready_at" } while the submission is queued; null otherwise. See Queue position and ETA.
integer
How many grading runs this submission has had (re-evaluations add one).
integer | null
The rubric version the latest run graded with.
number | null
What was charged: the quote once grading completes, 0 when no AI grading was needed, and null while grading has not completed (and for failed or cancelled runs, which are free).
object | null
{ "code", "message" } when status is failed. See Failure codes.
object | null
The metadata you sent.
string
ISO 8601, UTC.
string
Moves whenever anything you can see about the submission changes.

Statuses

needs_review and finalized are separate flags, not statuses: a graded submission can still need review.

Failure codes

Follow a submission

Read one submission with GET /submissions/{submission_id}.
How often to poll depends on who is waiting. For one typed submission a user is waiting on, poll every 2 to 5 seconds and back off to 10 seconds. A handwritten copy typically takes a few minutes, and longer copies take longer, so poll it every 15 to 30 seconds. With many submissions in flight, poll the feed every 30 to 60 seconds instead of each submission. See Syncing results.

Queue position and ETA

While a submission is queued, queue tells you where it stands:
  • position is the number of your institute’s submissions in the same lane that are ahead of it. 0 means it is next. Other institutes’ work is not counted in your position.
  • estimated_ready_at is when the result should be ready, estimated from recent grading times and current load. It is a single estimate, never earlier than 30 seconds from now.
Once grading starts, queue becomes null and progress.estimated_ready_at carries the estimate, refined as questions are marked. Handwritten copies and typed answers wait in separate lanes, so a large batch of copies does not hold up typed answers.

List an exam’s submissions

GET /exams/{exam_id}/submissions lists the exam’s live submissions, ordered by (updated_at, id).

The submission feed

GET /submissions?updated_since=… returns submissions from all your exams that changed after updated_since, ordered by (updated_at, id). Use it to keep your system in sync without polling each submission. It also returns replaced and deleted submissions, so your sync loop learns about them (check state). Follow these rules when you read the feed:
  • Keep updated_since the same for every page of one pass, and follow next_cursor until has_more is false. Don’t move updated_since forward in the middle of a pass: rows written together share the same updated_at, and you would skip some of them.
  • Remember the newest updated_at you processed (your watermark). Start the next pass from the watermark minus about 2 minutes, so rows committed a moment late are not missed.
  • Upsert by id and skip a row whose updated_at is not newer than what you already stored. The overlap means you see some rows twice; this check makes that harmless.
Run a pass every 30 to 60 seconds and save the watermark after each one. Syncing results has the complete worker, and Pagination and syncing explains the cursor rules.
A submission appears in the feed again every time it changes: when it moves from queued to grading to graded, when it is reviewed or finalized, and when it is replaced or deleted. Changes made by teachers in the Vacademy dashboard appear too. Engine progress reaches the feed within a few seconds. Webhooks are on the roadmap; until then, the feed is the way to hear about changes.

Replace a submission

To send a corrected or rescanned copy for a candidate who already has a submission, create a new submission with "replace": true.
The old submission’s grading is stopped, its state becomes replaced and its replaced_by points to the new one. The new submission is quoted and charged as a new copy; a cancelled grading run of the old one is not charged. A finalized submission cannot be replaced (409 submission_finalized): unfinalize it first.

Cancel grading

POST /submissions/{submission_id}/cancel stops grading. A queued submission is cancelled at once; a running one is stopped. Cancelled submissions are not charged.
Cancelling an already cancelled submission returns the same response. A submission whose grading has finished (graded or failed) is refused with 409 already_completed. To grade a cancelled submission later, re-evaluate it.

Delete a submission

DELETE /submissions/{submission_id} removes a submission that is not finalized, for example one made with the wrong upload. Any grading in progress is stopped.
After a delete, the candidate can be given a new submission on the exam. The deleted submission still appears in the feed with state: "deleted", but its result is no longer available. An upload can be used only once, even if its submission is deleted: upload the file again for the new submission.

Re-evaluate

POST /submissions/{submission_id}/re-evaluate grades the whole copy again, for example after you changed the rubric or after a timed_out failure. It returns 202 Accepted with the submission object.
boolean
default:"true"
Keep the marks of questions a teacher already overrode. Set false to have every question graded again.
Re-evaluation is charged again at the same price as the original submission (per page, or per non-blank long answer). It also clears any approval, so review the new result again.
Re-evaluation is refused with 409 evaluation_in_progress while a run is still active (cancel it or wait), and with 409 submission_finalized once the result is finalized. Re-evaluating single questions (question_ids) is not available yet and is refused with 422 feature_not_available.

Retries and idempotency

Every POST above accepts an optional Idempotency-Key header. A retry with the same key and the same body returns the stored response with Idempotent-Replayed: true, so a network timeout never creates or charges a submission twice. The one-live-submission rule protects you too: a duplicate submission for the same candidate and exam is refused with 409 submission_exists.

Errors