evaleasy API

Send assignments and exam papers, and get marked reports back. Everything is asynchronous: you create a submission, workers mark it in the background, and you get the result live, on a webhook, or by polling.

Quickstart
  1. Create an account, top up credits and create an API key.
  2. Send a paper. Only the paper is required.
  3. Get the report on a webhook or from the submission.
curl https://YOUR_API_HOST/v1/submissions \
  -H "api-key: $EVALEASY_API_KEY" \
  -F [email protected] \
  -F 'marking_guide=1. B
2. True
3. Paris, Rome (2 marks)
Negative marking: -1 for each wrong multiple choice answer.'

The submission comes back straight away, still queued. These are its main fields, and One paper shows them all.

{ "id": "sub_4fQ9...", "status": "queued", "stage": "queued", "display_name": "answers", "page_count": 2, "credits_charged": 2 }
Authentication

Send your key as api-key: ee_live_…. It's the only way to send one: Authorization: Bearer isn't accepted, and neither is X-API-Key.

curl https://YOUR_API_HOST/v1/credits \
  -H "api-key: $EVALEASY_API_KEY"

Keys are created in the dashboard. Each key is shown once, when you create it, and can't be recovered. If you lose one, create a new one and revoke the old one.

A missing or invalid key, or a key sent in any other header, gets a 401:

{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid API key. Send it in the \"api-key\" header."
  }
}
One paper
One student's work: PDFs, photos of pages, a Word or text file, typed text, or a mix.
POST/v1/submissionsCreate a submission (multipart). Returns 202.
GET/v1/submissions/:idStatus and the full report.
GET/v1/submissionsHistory, newest first, without the question-by-question report. Filters: status, batch_id, external_id, rubric_id.
GET/v1/submissions/:id/report.csvThe report as a spreadsheet: each question, the student's answer, the correct answer, the marks and why any were lost, then the total. A 409 until the paper is marked.
GET/v1/submissions/:id/files/:fileIdDownload a file you sent.
POST/v1/submissions/:id/redoMark a finished paper again from scratch, for what it cost the first time, with the same files and settings. Returns 202 and a new submission with a new id, which replaces this one.
DELETE/v1/submissions/:idDelete the paper, report and files. Refunded if not marked yet.
FieldWhat it does
filesThe paper: PDF, JPEG, PNG, WebP, Word (.docx) or plain text. Several files are pages of one paper, in order.
file_urlsThe paper, as a link to each file instead of the file itself. One URL, or several for several pages, read after any files you uploaded.
textA typed answer, used exactly as written, alone or with files.
student_nameWho it's from. If left out, the name on the paper is used when one can be read, and otherwise the file's name.
external_id, metadataYour own reference and a JSON object, returned with the result.
Idempotency-Key (header)Retrying with the same key returns the same submission and charges nothing.

Plus any of the marking fields below.

Limits: 20 MB a file, and 20 files and 30 pages a paper. A larger file gets a 413. A longer paper gets a 400.

Files are sent in the request itself as multipart/form-data. If you only send URLs or a typed answer, the request can be JSON instead, with rubric and metadata as objects. A field or file field we don't recognise gets a 400, so a misspelt answer_key can't mark a paper without its key.

Files at a URL

If your files are already in your own storage, send links instead of uploading them. Use a signed link that expires, such as an S3 or Google Cloud Storage presigned URL, rather than leaving students' papers public.

curl https://YOUR_API_HOST/v1/submissions \
  -H "api-key: $EVALEASY_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "file_urls": ["https://your-bucket.s3.amazonaws.com/priya-1.jpg?X-Amz-Signature=...",
                  "https://your-bucket.s3.amazonaws.com/priya-2.jpg?X-Amz-Signature=..."],
    "answer_key_urls": "https://your-bucket.s3.amazonaws.com/key.pdf?X-Amz-Signature=...",
    "student_name": "Priya Nair"
  }'

We download each file once, while the request is open, and from then on it's the same as an upload: the same limits and page count, the same charge, and our own private copy. So the link only has to work for that request. We don't keep the URL.

URLs must be https, on a public address, and a direct link to the file. A failed download gets a 400 with the code file_download_failed, the whole paper is refused and nothing is charged. All the files in one request have 25 seconds to download.

Creating a submission returns it with status 202. result stays null until the paper is marked.

{
  "id": "sub_4fQ9...",
  "object": "submission",
  "status": "queued",
  "stage": "queued",
  "batch_id": null,
  "title": "Class 9 unit test",
  "student_name": "Asha Rao",
  "display_name": "Asha Rao",
  "rubric_id": null,
  "rubric_version": null,
  "rubric": null,
  "marking_guide": null,
  "references": [
    {
      "id": "file_2mXc...",
      "filename": "questions.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 184320,
      "page_count": 2,
      "role": "question_paper"
    },
    {
      "id": "file_8pLw...",
      "filename": "answer-key.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 92160,
      "page_count": 1,
      "role": "answer_key"
    }
  ],
  "strictness": "medium",
  "paper_type": "mixed",
  "mark_everything": true,
  "external_id": null,
  "metadata": {},
  "page_count": 2,
  "credits_charged": 2,
  "text": null,
  "text_page_count": 0,
  "files": [
    {
      "id": "file_5rTn...",
      "filename": "answers.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 412160,
      "page_count": 2
    }
  ],
  "result": null,
  "error": null,
  "created_at": "2026-11-01T09:30:00.000Z",
  "started_at": null,
  "completed_at": null
}
Many papers
A whole class, marked the same way.
POST/v1/batchesStart a batch with the marking fields below. Returns its id.
POST/v1/submissionsSend each paper with batch_id. One student per request.
GET/v1/batches/:idSettings and progress: counts by status, needs_review, average_score, highest_score, lowest_score.
GET/v1/batches/:id/export.csvMarks for the whole class: one row per paper, one column per question.
GET/v1/batches/:id/report.csvEvery answer in the class: one row per student per question, with the answer, the marks and why any were lost.
GET/v1/batches/:id/plagiarismCompare the batch's papers with each other. See Plagiarism.
GET/v1/batchesYour batches, newest first.
DELETE/v1/batches/:idDelete the batch and all its papers. Anything not marked yet is refunded.
BATCH=$(curl -s https://YOUR_API_HOST/v1/batches \
  -H "api-key: $EVALEASY_API_KEY" \
  -F title="Class 9 unit test" \
  -F [email protected] \
  -F [email protected] | jq -r .id)

for f in papers/*.pdf; do
  curl https://YOUR_API_HOST/v1/submissions \
    -H "api-key: $EVALEASY_API_KEY" \
    -H "Idempotency-Key: $BATCH:$f" \
    -F batch_id=$BATCH -F files=@"$f"
done

The answer key is read once and every paper is marked against that one reading. A paper in a batch can't change the batch's settings: sending its own title, strictness, paper_type, mark_everything, marking_guide, rubric, question_paper, answer_key, references or their URLs gets a 400. It can send student_name, external_id and metadata.

Each student's paper is sent as its own file, or its own set of photos, in its own request. The server never splits a file, so everything in a file counts as one student's work.

A batch comes back with its progress. average_score, highest_score and lowest_score are null until a paper is completed.

{
  "id": "bat_7hK2...",
  "object": "batch",
  "title": "Class 9 unit test",
  "strictness": "medium",
  "paper_type": "mixed",
  "mark_everything": true,
  "rubric_id": null,
  "rubric_version": null,
  "rubric": null,
  "marking_guide": null,
  "references": [
    {
      "id": "file_2mXc...",
      "filename": "questions.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 184320,
      "page_count": 2,
      "role": "question_paper"
    },
    {
      "id": "file_8pLw...",
      "filename": "answer-key.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 92160,
      "page_count": 1,
      "role": "answer_key"
    }
  ],
  "progress": {
    "total": 30,
    "queued": 0,
    "processing": 1,
    "completed": 28,
    "failed": 1,
    "needs_review": 4,
    "average_score": 71.5,
    "highest_score": 96,
    "lowest_score": 34
  },
  "created_at": "2026-11-01T09:30:00.000Z"
}
Plagiarism
Checks whether papers in a batch look copied from each other.
GET/v1/batches/:id/plagiarismThe report for one batch.

This is code comparing the batch's papers with each other. It makes no model calls and costs no credits. It's worked out when you ask, so a paper that was redone or deleted is reflected straight away.

It doesn't search the web and it doesn't detect AI writing, so it only finds copying between papers in the same batch. It never changes marks. A high score is a reason to read the two papers, not proof of copying.

A pair is listed only when the match is strong. For written work, at least 30 words and half of the shorter paper must match. For wrong answers, at least 5 unusual ones and 80% of the shorter list must be the same. Text from the question paper, answer key or marking guide doesn't count, and neither does text or a wrong answer that many papers share. Wrong answers only count when your answer key settled them, and true or false questions never count.

{
  "object": "plagiarism_report",
  "batch_id": "bat_7hK2...",
  "papers_compared": 28,
  "papers_pending": 2,
  "flagged_papers": 4,
  "pairs": [
    {
      "kind": "written",
      "score": 94,
      "a": { "submission_id": "sub_4fQ9...", "display_name": "Asha Rao" },
      "b": { "submission_id": "sub_9dJ1...", "display_name": "Bea Lopez" },
      "matched_words": 142,
      "shared_wrong_answers": 0,
      "matches": [
        {
          "a_question": "3",
          "b_question": "3",
          "text": "the chloroplasts use light energy to turn carbon dioxide and water into glucose"
        }
      ],
      "wrong_answers": []
    },
    {
      "kind": "wrong_answers",
      "score": 90,
      "a": { "submission_id": "sub_3kP5...", "display_name": "Chen Wu" },
      "b": { "submission_id": "sub_6vR8...", "display_name": "Dev Shah" },
      "matched_words": 0,
      "shared_wrong_answers": 9,
      "matches": [],
      "wrong_answers": [
        { "question": "4", "answer": "C" }, { "question": "7", "answer": "A" }, { "question": "9", "answer": "D" },
        { "question": "12", "answer": "B" }, { "question": "13", "answer": "C" }, { "question": "16", "answer": "A" },
        { "question": "18", "answer": "D" }, { "question": "21", "answer": "B" }, { "question": "24", "answer": "C" }
      ]
    }
  ]
}
FieldWhat it does
kindwritten: the same passages in written answers. wrong_answers: the same wrong answers to objective questions.
score0 to 100. For written, how much of the shorter paper's written work also appears in the other. For wrong_answers, how many of the shorter list of wrong objective answers are the same answer in both.
pairsSorted by score, highest first, and at most 500. A pair that trips both kinds appears once for each kind.
matches, wrong_answersThe evidence: at most 5 matches and 10 wrong_answers per pair. On a paper marked with a rubric, a_question and b_question are empty.
papers_comparedFinished papers with something to compare. A paper with nothing to compare isn't counted, so it hasn't been cleared.
flagged_papersHow many different papers appear in pairs.
papers_pendingPapers still queued or being marked. Ask again when they're done. Failed papers are ignored.
How to mark
Every field is optional.
FieldWhat it does
marking_guideFree text: an answer key, a rubric, instructions, negative marking, marks per question, anything.
question_paperFiles: the questions (PDF, photos, Word or text). Useful when students' sheets show only answers. It's never used as a source of correct answers.
answer_keyFiles: the correct answers and marks (PDF, photos, Word or text).
referencesFiles that aren't sorted into question_paper or answer_key.
question_paper_urls, answer_key_urls, reference_urlsThe same files, as links instead of uploads. See Files at a URL.
paper_typeWhat kind of paper this is: mixed (the default, for mixed or not sure), mcq (multiple choice), fill_blank (fill in the blanks), descriptive (written answers) or question_answer (each question is written on the sheet with its answer below it). A strong hint, not a hard rule: a question that plainly isn't that type is treated as the type it really is.
strictnessnormal (lenient), medium (default) or hard (strict). Applies to written answers and near-misses, not to exact comparisons.
mark_everythingtrue (default) makes every mark final: nothing is flagged, and an answer that can't be made out earns no marks. false flags what stays unclear for a teacher instead. See Results.
titleA name for the assessment.
rubric or rubric_idA structured rubric as JSON, or the id of a saved one. Marks by criterion instead of by question.
curl https://YOUR_API_HOST/v1/submissions \
  -H "api-key: $EVALEASY_API_KEY" \
  -F [email protected] \
  -F [email protected] \
  -F [email protected] \
  -F paper_type=mcq

question_paper, answer_key and references aren't charged. Together they can have up to 10 files and 40 pages.

In a response, each item in references has a role: question_paper, answer_key or reference. Files stored before roles existed read as reference. Submissions and batches also return paper_type, and older ones read as mixed. They return mark_everything too, and older ones read as false, because they were marked with flags.

A structured rubric goes in the rubric field as a JSON string, with the same shape as a saved rubric:

{
  "criteria": [
    { "title": "Reasoning", "max_points": 10 },
    { "title": "Working shown", "max_points": 5 }
  ]
}

Word files are read as text, keeping what people use to show answers: automatic question numbers and option letters, bold, underline, highlight and ticked boxes. A student can choose an option by highlighting it, and a teacher can mark the correct one in a key the same way. Pictures in a Word file aren't read, and the report flags them: upload those files as PDFs instead.

What happens to each kind of question:

QuestionWith an answer keyWithout one
Multiple choice, true or falseCompared exactly in code. Only the exact set of correct options earns the marks.The model works out the answer, and code compares it.
Fill in the blanks, numericalCompared in code, ignoring case and punctuation; numbers by value. Near-misses go to the model under the strictness policy. Each blank is an equal share.Marked by the model.
Short and long answersMarked by the model against your key, in 2 or 3 passes; the median counts.The same, against an answer the model works out.

Marks come from your key, then what's printed on the paper, then a rule in your guide. Failing those: 1 for objective questions, 1 per blank, 5 for short answers and 10 for long ones. A question in your key that isn't on the paper counts as unanswered.

Results
{
  "status": "completed",
  "display_name": "Asha Rao",
  "result": {
    "mode": "questions",                   // or "rubric"
    "score": 87.5,                         // always out of 100
    "marks": { "awarded": 7, "total": 8 },
    "student_name": "Asha Rao",
    "feedback": { "summary": "...", "strengths": ["..."], "improvements": ["..."] },
    "questions": [
      {
        "number": "3",
        "type": "fill_blank",              // mcq, true_false, fill_blank, numerical, short_answer, long_answer
        "verdict": "partial",              // correct, partial, incorrect, unanswered, unclear, not_marked
        "marks_awarded": 1,
        "max_marks": 2,
        "student_answer": "Paris, Madrid",
        "correct_answer": "Paris, Rome",
        "answer_source": "key",            // key, worked_out, none
        "marked_by": "code",               // code or model
        "blanks": [{ "label": "1", "student_answer": "Paris", "correct_answer": "Paris", "correct": true, "decided_by": "key" }],
        "rationale": "...",
        "confidence": "high",
        "needs_review": false
      }
    ],
    "consistency": { "read_passes": 2, "passes": 0, "confidence": "high", "needs_review": false, "review_reasons": [] },
    "warnings": [],                        // problems with the paper as a whole
    "transcription": { "legibility": "good", "notes": "", "pages": [{ "page_number": 1, "text": "..." }] },
    "grader": { "answer_key": "provided", "transcription_model": "...", "grading_model": null, "pipeline_version": "..." }
  }
}

Each paper is read twice, and a third time if the readings differ on a marked option or a blank. Check grader.answer_key: provided, partial or worked_out tells you whose answers the marks rest on.

With mark_everything on, the default, every mark is final and needs_review is always false. A multiple choice, true/false or one-blank answer that the readings can't settle, or that the reader had to guess at, gets the verdict unclear: no marks, still counted in the total, and no negative marking. A written answer is marked on what can be read, each of several blanks is marked on its own, and the median stands when marking passes disagree. read_disputed, other_readings and confidence still say how sure the marker was.

With mark_everything off, the same answers are marked as read and set needs_review, on the question and in consistency, and review_reasons says why. An answer that nothing can check, with no key and no question text, is not_marked and left out of the total either way.

warnings lists problems with the paper as a whole in both modes: question numbers that don't line up with your key, pages that couldn't be read next to a typed answer, and pictures or equations skipped in a Word file. With mark_everything off, they're in review_reasons as well.

With a structured rubric, mode is rubric and criteria holds a chosen level, evidence and rationale per criterion instead of questions.

Live updates

GET /v1/events is a stream of server-sent events for your account. A submission event is sent whenever a paper's status or stage changes. ready is sent on connecting: refresh then, because updates sent while you were disconnected aren't replayed.

event: submission
data: {"id":"sub_4fQ9...","status":"completed","stage":"done","batch_id":null,"score":87.5,"needs_review":false}

Stages run queued, reading, marking, done. You can also poll GET /v1/submissions/:id, which sends Retry-After while the paper is in progress.

Webhooks
POST/v1/webhook-endpointsRegister an endpoint, as below. Returns the signing secret.
GET/v1/webhook-endpointsList endpoints.
PATCH/v1/webhook-endpoints/:idUpdate url, events or enabled.
DELETE/v1/webhook-endpoints/:idDelete.
POST/v1/webhook-endpoints/:id/testSend a webhook.test event.
POST/v1/webhook-endpoints/:id/rotate-secretNew signing secret.
GET/v1/webhook-endpoints/:id/deliveriesDelivery log.

To register an endpoint, send this as JSON. Only url is required, and events defaults to ["*"].

{
  "url": "https://your-app.com/webhooks/evaleasy",
  "description": "Production server",
  "events": ["submission.completed", "submission.failed"]
}

You get back the endpoint with its signing secret:

{
  "id": "whe_1aZ7...",
  "object": "webhook_endpoint",
  "url": "https://your-app.com/webhooks/evaleasy",
  "description": "Production server",
  "events": ["submission.completed", "submission.failed"],
  "enabled": true,
  "secret": "whsec_...",
  "created_at": "2026-11-01T09:30:00.000Z",
  "updated_at": "2026-11-01T09:30:00.000Z"
}

Events: submission.completed and submission.failed ("*" for all). The body is { "id": "evt_...", "type", "created_at", "data": <submission> }. Respond with any 2xx within 15 seconds; failures are retried for about two days. Deliveries can arrive more than once, so de-duplicate on the webhook-id header.

Signatures follow Standard Webhooks, so any Standard Webhooks library verifies them:

// npm install standardwebhooks
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.EVALEASY_WEBHOOK_SECRET); // "whsec_..."

app.post("/webhooks/evaleasy", express.raw({ type: "application/json" }), (req, res) => {
  const event = wh.verify(req.body.toString(), req.headers); // throws if invalid
  if (event.type === "submission.completed") saveMarks(event.data);
  res.sendStatus(200);
});

Manually: HMAC-SHA256 over `${webhook-id}.${webhook-timestamp}.${raw body}`, keyed with the base64-decoded part of the secret after whsec_, base64-encoded, compared with the value after v1, in webhook-signature. Reject timestamps older than 5 minutes.

Saved rubrics
Optional: save a structured rubric to reuse by id. Saving again creates a new version, and earlier results keep the one they used.
POST/v1/rubricsSave a rubric, as below.
GET/v1/rubricsList.
GET/v1/rubrics/:idCurrent version, or ?version=.
PUT/v1/rubrics/:idReplace, creating a new version.
DELETE/v1/rubrics/:idArchive.
{
  "name": "Essay rubric",
  "instructions": "Mark the essay in question 5 only.",
  "criteria": [
    {
      "title": "Reasoning",
      "description": "Builds a clear argument from the text.",
      "max_points": 10
    },
    {
      "title": "Working shown",
      "levels": [
        { "points": 5, "description": "Every step is shown." },
        { "points": 2, "description": "Some steps are shown." },
        { "points": 0, "description": "No working is shown." }
      ]
    }
  ]
}

Only criteria is required, and each criterion needs only a title. A criterion is worth max_points, or its highest level, or 10 if it has neither. Without levels, it gets a standard set.

Credits

One credit per page: each photo or PDF page is a page, and typed text and Word or text files count one page per 3,000 characters. Charged when the paper is accepted, and refunded if it can't be marked. Question papers and answer keys aren't charged.

GET/v1/creditsBalance and pricing.
GET/v1/credits/ledgerHistory.
GET/v1/billing/packsCredit packs.
POST/v1/billing/checkoutStart a top-up, as below.

GET /v1/credits returns your balance:

{
  "object": "credits",
  "balance": 120,
  "credits_per_page": 1,
  "text_chars_per_page": 3000
}

To top up, send the id of a pack from GET /v1/billing/packs as JSON:

{
  "pack_id": "starter"
}

You get back a checkout_url to open and pay at. The credits are added once the payment goes through.

{
  "topup_id": "top_5gH3...",
  "checkout_url": "https://checkout.example.com/session/..."
}
Errors
{
  "error": {
    "code": "insufficient_credits",
    "message": "This needs 3 credits but your balance is 2. Top up to continue.",
    "required_credits": 3,
    "credit_balance": 2
  }
}
FieldWhat it does
400invalid_request: a field or file isn't valid, or isn't one we recognise. file_download_failed: we couldn't get a file from its URL.
401unauthorized: a missing or invalid key, or a key in the wrong header. Keys go in api-key only.
402insufficient_credits: top up and retry.
403email_not_verified: confirm your email first.
404not_found
413invalid_upload: a file is over 20 MB.
429rate_limited: slow down and retry. Retry-After says when. Each API key gets 600 requests a minute.

A failed paper has status: "failed" and an error: unreadable_submission, no_questions_found or grading_failed. Its credits are refunded.