For developers and LMS teams

Grading as an API call

POST the questions and the sheet. Get marks per question, the reason for each, what was read, and a confidence score.

Prepaid per sheet, charged only on success. Free test keys while you build.

What you can do here

  • One endpoint for a sheet: POST /grade with 1 to 100 questions, page URLs, uploads or typed answers, and marking instructions. The response has marks, reasons, status and confidence per question.
  • Built for batches: Presigned uploads, idempotency keys and 60 requests a minute. Run a class in parallel and retry safely.
  • Data you can build reports on: Per-question scores and statuses come back as structured JSON, so your own dashboards and reports can use them directly.

Grade one sheet

Send the questions, the marking key and the pages of one answer sheet. You get marks per question, the reason for each, what was read on the page, feedback and a confidence score. Pages can be public URLs, data URLs, or upload ids from the upload step below.

POST /grade (curl)
curl -X POST https://agent.proctorai.io/api/v1/grade \
  -H "Authorization: Bearer pai_live_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sheet-01-2026-10-27" \
  -d '{
    "title": "Physics unit test",
    "level": "High School",
    "instructions": "Award method marks even when the final answer is wrong.",
    "questions": [
      {
        "id": "q1",
        "prompt": "State Newton's second law and give its SI unit.",
        "maxMarks": 2,
        "expectedAnswer": "F = ma. Force is measured in newtons (N)."
      },
      {
        "id": "q2",
        "prompt": "A 4 kg mass accelerates at 3 m/s². Find the force.",
        "maxMarks": 3,
        "expectedAnswer": "F = 4 × 3 = 12 N"
      }
    ],
    "pages": ["https://example.com/scans/sheet-01-page-1.jpg"]
  }'
POST /grade (Node)
const res = await fetch("https://agent.proctorai.io/api/v1/grade", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PROCTOR_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "sheet-01-2026-10-27",
  },
  body: JSON.stringify({
    "title": "Physics unit test",
    "level": "High School",
    "instructions": "Award method marks even when the final answer is wrong.",
    "questions": [
      {
        "id": "q1",
        "prompt": "State Newton's second law and give its SI unit.",
        "maxMarks": 2,
        "expectedAnswer": "F = ma. Force is measured in newtons (N)."
      },
      {
        "id": "q2",
        "prompt": "A 4 kg mass accelerates at 3 m/s². Find the force.",
        "maxMarks": 3,
        "expectedAnswer": "F = 4 × 3 = 12 N"
      }
    ],
    "pages": ["https://example.com/scans/sheet-01-page-1.jpg"]
  }),
});

if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error}`);
const result = await res.json();
console.log(result.marks);            // { earned: 4, max: 5, percentage: 80 }
for (const q of result.questions) {
  console.log(q.questionId, q.earned, "/", q.max, q.needsReview ? "(check)" : "");
}
POST /grade (Python)
import os, requests

body = {
  "title": "Physics unit test",
  "level": "High School",
  "instructions": "Award method marks even when the final answer is wrong.",
  "questions": [
    {
      "id": "q1",
      "prompt": "State Newton's second law and give its SI unit.",
      "maxMarks": 2,
      "expectedAnswer": "F = ma. Force is measured in newtons (N)."
    },
    {
      "id": "q2",
      "prompt": "A 4 kg mass accelerates at 3 m/s². Find the force.",
      "maxMarks": 3,
      "expectedAnswer": "F = 4 × 3 = 12 N"
    }
  ],
  "pages": ["https://example.com/scans/sheet-01-page-1.jpg"]
}

res = requests.post(
    "https://agent.proctorai.io/api/v1/grade",
    headers={
        "Authorization": f"Bearer {os.environ['PROCTOR_API_KEY']}",
        "Idempotency-Key": "sheet-01-2026-10-27",
    },
    json=body,
    timeout=120,
)
res.raise_for_status()
result = res.json()
print(result["marks"])
for q in result["questions"]:
    print(q["questionId"], q["earned"], "/", q["max"], "(check)" if q["needsReview"] else "")
Response (JSON)
{
  "id": "grd_6718f0c2a1b9e4d3f2a1b0c9",
  "studentName": "Priya Menon",
  "rollNumber": "02",
  "marks": { "earned": 4, "max": 5, "percentage": 80 },
  "questions": [
    {
      "questionId": "q1",
      "label": "Q1",
      "earned": 2,
      "max": 2,
      "status": "answered",
      "feedback": "Law stated correctly with the unit.",
      "reasoning": "+1 F = ma stated; +1 newton given as the SI unit",
      "studentAnswer": "Force equals mass times acceleration, measured in newtons.",
      "pages": [1],
      "confidence": 0.94,
      "needsReview": false
    },
    {
      "questionId": "q2",
      "label": "Q2",
      "earned": 2,
      "max": 3,
      "status": "answered",
      "feedback": "Method correct; the final value was written as 7 N.",
      "reasoning": "+1 formula; +1 substitution; -1 arithmetic slip",
      "studentAnswer": "F = 4 × 3 = 7 N",
      "pages": [1],
      "confidence": 0.71,
      "needsReview": true,
      "reviewReason": "Working and final answer disagree"
    }
  ],
  "strengths": ["Knows the law and its unit"],
  "improvements": ["Check arithmetic before writing the final value"],
  "overallFeedback": "Solid understanding; one careless slip.",
  "pageCount": 1,
  "billing": { "gradingsCharged": 0.4, "balance": 199.6 }
}

Upload scans first

For PDFs or private images, ask for upload slots, PUT each file to its URL, then grade with the upload ids. Images may be JPEG, PNG, WebP or HEIC up to 10 MB; PDFs up to 30 MB. A PUT URL is valid for 15 minutes and the file is kept for 72 hours, then deleted.

POST /uploads, then PUT, then /grade (curl)
# 1. Ask for an upload slot
curl -X POST https://agent.proctorai.io/api/v1/uploads \
  -H "Authorization: Bearer pai_live_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"files":[{"contentType":"application/pdf","size":2381904,"name":"sheet-01.pdf"}]}'
# -> { "uploads": [{ "uploadId": "upl_...", "method": "PUT", "url": "...", "headers": {...}, "expiresAt": "..." }], "retainedUntil": "..." }

# 2. Send the file to the signed URL exactly as returned
curl -X PUT "<url from step 1>" -H "Content-Type: application/pdf" --data-binary @sheet-01.pdf

# 3. Grade with the upload id instead of pages
curl -X POST https://agent.proctorai.io/api/v1/grade \
  -H "Authorization: Bearer pai_live_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"questions":[{"id":"q1","prompt":"...","maxMarks":2}],"files":[{"uploadId":"upl_..."}]}'
POST /uploads, then PUT, then /grade (Node)
import { readFile } from "node:fs/promises";

const headers = { Authorization: `Bearer ${process.env.PROCTOR_API_KEY}`, "Content-Type": "application/json" };
const pdf = await readFile("sheet-01.pdf");

// 1. Ask for an upload slot
const slot = await fetch("https://agent.proctorai.io/api/v1/uploads", {
  method: "POST",
  headers,
  body: JSON.stringify({ files: [{ contentType: "application/pdf", size: pdf.byteLength, name: "sheet-01.pdf" }] }),
}).then((r) => r.json());
const [upload] = slot.uploads;

// 2. PUT the bytes to the signed URL with the headers it asks for
await fetch(upload.url, { method: upload.method, headers: upload.headers, body: pdf });

// 3. Grade by upload id
const result = await fetch("https://agent.proctorai.io/api/v1/grade", {
  method: "POST",
  headers,
  body: JSON.stringify({ questions, files: [{ uploadId: upload.uploadId }] }),
}).then((r) => r.json());
POST /uploads, then PUT, then /grade (Python)
import os, requests

headers = {"Authorization": f"Bearer {os.environ['PROCTOR_API_KEY']}"}
pdf = open("sheet-01.pdf", "rb").read()

# 1. Ask for an upload slot
slot = requests.post(
    "https://agent.proctorai.io/api/v1/uploads",
    headers=headers,
    json={"files": [{"contentType": "application/pdf", "size": len(pdf), "name": "sheet-01.pdf"}]},
).json()
upload = slot["uploads"][0]

# 2. PUT the bytes to the signed URL with the headers it asks for
requests.put(upload["url"], headers=upload["headers"], data=pdf).raise_for_status()

# 3. Grade by upload id
result = requests.post(
    "https://agent.proctorai.io/api/v1/grade",
    headers=headers,
    json={"questions": questions, "files": [{"uploadId": upload["uploadId"]}]},
    timeout=120,
).json()

What a request can carry

FieldLimitsNotes
questions1 to 100id (letters, digits, . _ -), prompt up to 4,000 chars, maxMarks, expectedAnswer up to 8,000 chars, options (2 to 10), subQuestions (1 to 20)
pagesup to 20Public URLs or data URLs, one per page
filesupload idsFrom POST /uploads; a PDF counts by its pages, 20 at most
answersup to 500Typed answers: questionId, optional subQuestionId, text
answerSheetText50,000 charsA whole typed script, if you already have the text
title, level, studentName, instructions200, 80, 120, 2,000 charsContext the grader uses for tone and strictness
Idempotency-Key header8 to 255 charsRepeat a call safely; the replay carries Idempotent-Replayed: true
POST /grade fields. Unknown fields are rejected.

Limits, price and errors

  • Price: 0.4 of a grading per sheet, about $0.10 or ₹10, up to 20 pages. Charged only when the call returns 200. Bought gradings only; plan allowances are not touched.
  • Rate limits: 60 grade requests a minute on live keys, 20 a minute on test keys, 120 upload requests a minute, and 500 pending uploads per account.
  • A sheet takes 20 to 90 seconds. Keep client timeouts at two minutes or more.
  • Up to 5 active keys per account. A key is shown once; rotate it from Settings, API keys.
  • Uploads live in private storage and are deleted on grading, or within 72 hours. Results are kept encrypted for 72 hours so an idempotent retry can return them. Otherwise only billing is recorded.
StatusCodeWhen
400invalid_request, invalid_jsonA field is missing, too long or the wrong shape
401invalid_api_keyNo key, a revoked key, or the wrong prefix
402insufficient_balanceNot enough gradings; body includes balance and price
409idempotency_in_progressThe same Idempotency-Key is still being processed
422unreadable_page, too_many_pages, upload_not_found, upload_incomplete, idempotency_key_mismatchThe sheet or its uploads cannot be used as sent
429rate_limited, too_many_uploadsSlow down; retry after the Retry-After header
502grading_failedThe model could not produce a result; nothing is charged
503unavailableTemporary; retry with backoff
Error codes. The body is { "error": "<code>", "message": "..." } plus extra fields where noted.

Other doors into the same engine

  1. Claude connector: Teachers can connect their account to Claude at agent.proctorai.io/api/mcp (OAuth, about 45 tools) and grade by chatting. Same allowance, scoped to their account.
  2. Moodle plugin: A free, GPL feedback plugin for the Assignment activity, built on this API. Admins paste a key once; teachers approve every draft mark in the normal grader.
  3. Your LMS or ERP: Most connections are a thin layer over POST /grade and POST /uploads. Tell us where your assignments live.

Plan that fits

Pay per sheet. 0.4 of a grading per sheet, charged only on success. Test keys are free.

Questions

What does a sheet cost?

0.4 of a grading per sheet, about $0.104 or ₹10, drawn from a prepaid balance. You are charged only when the call returns a 200. Keys that start with pai_test_ are free and return sample marks.

What are the limits?

1 to 100 questions per call, 60 requests a minute, images up to 10 MB and PDFs up to 30 MB through presigned uploads. A sheet takes 20 to 90 seconds.

What happens to the files we send?

Uploads go to private storage and are deleted once graded, and within 72 hours at the latest. Idempotent results are kept encrypted for 72 hours so retries are safe. Otherwise only billing is recorded.

Do we need the API if we run Moodle?

No. The free Moodle plugin (v0.2.0, GPL) grades assignment submissions on Moodle 4.5 to 5.3 using the same service. It never sends student names or emails, and the teacher releases the marks.

Open the API docs