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.
- Create an account, top up credits and create an API key.
- Send a paper. Only the paper is required.
- 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 }
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." } }
| POST | /v1/submissions | Create a submission (multipart). Returns 202. |
| GET | /v1/submissions/:id | Status and the full report. |
| GET | /v1/submissions | History, newest first, without the question-by-question report. Filters: status, batch_id, external_id, rubric_id. |
| GET | /v1/submissions/:id/report.csv | The 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/:fileId | Download a file you sent. |
| POST | /v1/submissions/:id/redo | Mark 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/:id | Delete the paper, report and files. Refunded if not marked yet. |
| Field | What it does |
|---|---|
| files | The paper: PDF, JPEG, PNG, WebP, Word (.docx) or plain text. Several files are pages of one paper, in order. |
| file_urls | The 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. |
| text | A typed answer, used exactly as written, alone or with files. |
| student_name | Who 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, metadata | Your 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 }
| POST | /v1/batches | Start a batch with the marking fields below. Returns its id. |
| POST | /v1/submissions | Send each paper with batch_id. One student per request. |
| GET | /v1/batches/:id | Settings and progress: counts by status, needs_review, average_score, highest_score, lowest_score. |
| GET | /v1/batches/:id/export.csv | Marks for the whole class: one row per paper, one column per question. |
| GET | /v1/batches/:id/report.csv | Every answer in the class: one row per student per question, with the answer, the marks and why any were lost. |
| GET | /v1/batches/:id/plagiarism | Compare the batch's papers with each other. See Plagiarism. |
| GET | /v1/batches | Your batches, newest first. |
| DELETE | /v1/batches/:id | Delete 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" }
| GET | /v1/batches/:id/plagiarism | The 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" } ] } ] }
| Field | What it does |
|---|---|
| kind | written: the same passages in written answers. wrong_answers: the same wrong answers to objective questions. |
| score | 0 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. |
| pairs | Sorted by score, highest first, and at most 500. A pair that trips both kinds appears once for each kind. |
| matches, wrong_answers | The 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_compared | Finished papers with something to compare. A paper with nothing to compare isn't counted, so it hasn't been cleared. |
| flagged_papers | How many different papers appear in pairs. |
| papers_pending | Papers still queued or being marked. Ask again when they're done. Failed papers are ignored. |
| Field | What it does |
|---|---|
| marking_guide | Free text: an answer key, a rubric, instructions, negative marking, marks per question, anything. |
| question_paper | Files: 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_key | Files: the correct answers and marks (PDF, photos, Word or text). |
| references | Files that aren't sorted into question_paper or answer_key. |
| question_paper_urls, answer_key_urls, reference_urls | The same files, as links instead of uploads. See Files at a URL. |
| paper_type | What 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. |
| strictness | normal (lenient), medium (default) or hard (strict). Applies to written answers and near-misses, not to exact comparisons. |
| mark_everything | true (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. |
| title | A name for the assessment. |
| rubric or rubric_id | A 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:
| Question | With an answer key | Without one |
|---|---|---|
| Multiple choice, true or false | Compared 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, numerical | Compared 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 answers | Marked 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.
{ "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.
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.
| POST | /v1/webhook-endpoints | Register an endpoint, as below. Returns the signing secret. |
| GET | /v1/webhook-endpoints | List endpoints. |
| PATCH | /v1/webhook-endpoints/:id | Update url, events or enabled. |
| DELETE | /v1/webhook-endpoints/:id | Delete. |
| POST | /v1/webhook-endpoints/:id/test | Send a webhook.test event. |
| POST | /v1/webhook-endpoints/:id/rotate-secret | New signing secret. |
| GET | /v1/webhook-endpoints/:id/deliveries | Delivery 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.
| POST | /v1/rubrics | Save a rubric, as below. |
| GET | /v1/rubrics | List. |
| GET | /v1/rubrics/:id | Current version, or ?version=. |
| PUT | /v1/rubrics/:id | Replace, creating a new version. |
| DELETE | /v1/rubrics/:id | Archive. |
{ "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.
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/credits | Balance and pricing. |
| GET | /v1/credits/ledger | History. |
| GET | /v1/billing/packs | Credit packs. |
| POST | /v1/billing/checkout | Start 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/..." }
{ "error": { "code": "insufficient_credits", "message": "This needs 3 credits but your balance is 2. Top up to continue.", "required_credits": 3, "credit_balance": 2 } }
| Field | What it does |
|---|---|
| 400 | invalid_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. |
| 401 | unauthorized: a missing or invalid key, or a key in the wrong header. Keys go in api-key only. |
| 402 | insufficient_credits: top up and retry. |
| 403 | email_not_verified: confirm your email first. |
| 404 | not_found |
| 413 | invalid_upload: a file is over 20 MB. |
| 429 | rate_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.