← Answer Sheet Grader/API
Your token

Answer Sheet Grader API

The same run the page makes, from your own code: one student's answer sheet per run. You send the page photos and your marking key; the model returns, for every question in the key, a transcription, proposed marks with the rubric lines met, a justification, feedback for the student and a flag when a person should check. The reply is a first pass - cap each mark at the question's max, add up the totals yourself, and have a teacher review flagged answers, exactly as the page does.

Base URL https://api.skillsafe.ai/v1/app-api. Every response is {"ok":true,"data":…} or {"ok":false,"error":{"code","message"}}. Runs are paid per run with SkillSafe credits from the signed-in account; /me and /estimate are free.

Errors

HTTPcodemeaning
400validation_errorBody is not a JSON object, or a field has the wrong type.
401unauthorizedMissing, expired or guest token on a metered call — sign in for a personal token.
402insufficient_creditsBalance below the run's minimum. Top up at skillsafe.ai.
404not_foundA $files id that does not exist or belongs to another session.
429rate_limitedSlow down and retry with the same Idempotency-Key.

1. Get a token

Open the token page, sign in, and copy your token. Guest tokens can call /me and /estimate but not run.

2. Check the session — GET /me

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer YOUR_TOKEN"
import requests
H = {"Authorization": "Bearer YOUR_TOKEN"}
print(requests.get("https://api.skillsafe.ai/v1/app-api/me", headers=H).json()["data"])
const H = { Authorization: "Bearer YOUR_TOKEN" };
const me = await (await fetch("https://api.skillsafe.ai/v1/app-api/me", { headers: H })).json();
console.log(me.data.credits);
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", nil)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
  .header("Authorization", "Bearer YOUR_TOKEN").build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"; require "json"
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri); req["Authorization"] = "Bearer YOUR_TOKEN"
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/me");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN"]]);
echo curl_exec($ch);
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_TOKEN");
Console.WriteLine(await http.GetStringAsync("https://api.skillsafe.ai/v1/app-api/me"));

3. Upload the pages — POST /files

Multipart, field file. One call per page, in page order (up to 4). Shrink each first - about 1600 px on the long edge keeps handwriting readable. File ids belong to the token that uploaded them.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/files \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@page1.jpg;type=image/jpeg"
# -> {"ok":true,"data":{"file":{"file_id":"fil_...", ...}}}
with open("page1.jpg", "rb") as f:
    r = requests.post("https://api.skillsafe.ai/v1/app-api/files", headers=H, files={"file": ("page1.jpg", f, "image/jpeg")})
file_id = r.json()["data"]["file"]["file_id"]
const fd = new FormData();
fd.append("file", photoBlob, "page1.jpg");
const up = await (await fetch("https://api.skillsafe.ai/v1/app-api/files", { method: "POST", headers: H, body: fd })).json();
const fileId = up.data.file.file_id;
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, _ := w.CreateFormFile("file", "page1.jpg")
photo, _ := os.ReadFile("page1.jpg")
part.Write(photo)
w.Close()
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/files", &buf)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Content-Type", w.FormDataContentType())
res, _ := http.DefaultClient.Do(req)
// Any multipart client works; field name "file".
// e.g. with OkHttp:
RequestBody body = new MultipartBody.Builder().setType(MultipartBody.FORM)
  .addFormDataPart("file", "page1.jpg", RequestBody.create(photoBytes, MediaType.get("image/jpeg"))).build();
Request req = new Request.Builder().url("https://api.skillsafe.ai/v1/app-api/files")
  .header("Authorization", "Bearer YOUR_TOKEN").post(body).build();
uri = URI("https://api.skillsafe.ai/v1/app-api/files")
req = Net::HTTP::Post.new(uri); req["Authorization"] = "Bearer YOUR_TOKEN"
req.set_form([["file", File.open("page1.jpg"), { content_type: "image/jpeg" }]], "multipart/form-data")
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
file_id = JSON.parse(res.body)["data"]["file"]["file_id"]
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/files");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN"],
  CURLOPT_POSTFIELDS => ["file" => new CURLFile("page1.jpg", "image/jpeg")]]);
$fileId = json_decode(curl_exec($ch), true)["data"]["file"]["file_id"];
var form = new MultipartFormDataContent();
var img = new ByteArrayContent(photoBytes);
img.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg");
form.Add(img, "file", "page1.jpg");
var up = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/files", form);

4. Input fields

fieldtyperequiredmeaning
taskstringyesAlways "grade".
keystringyesThe marking key as JSON text (a string, not an object): {"title","questions":[{"q","max","question","model_answer","rubric":[{"line","text","marks"}]}]}. q is echoed back; max caps the question; rubric line numbers start at 1 and marks may be null.
pagesnumbernoHow many photos are attached.
policystringno"balanced" (default), "strict" or "lenient" (half marks for a partly made point).
notestringnoA marking instruction: "ignore spelling", "Q3 needs two examples for full marks".
$filesarrayyesThe page file ids from step 3, in page order.

5. Price it — POST /estimate (free)

hold_credits is the most the run can reserve; you are charged charged_credits, usually far less. 10,000 credits = $1.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json"  \
  -d '{"task":"grade","key":"{\"title\":\"Quiz\",\"questions\":[{\"q\":\"1\",\"max\":2,\"rubric\":[{\"line\":1,\"text\":\"Names the xylem\",\"marks\":1},{\"line\":2,\"text\":\"Water enters by osmosis\",\"marks\":1}]}]}","pages":1,"policy":"balanced","note":"","$files":["FILE_ID"]}'
import json
key_json = json.dumps({"title": "Quiz", "questions": [{"q": "1", "max": 2, "rubric": [
  {"line": 1, "text": "Names the xylem", "marks": 1}, {"line": 2, "text": "Water enters by osmosis", "marks": 1}]}]})
body = {"task": "grade", "key": key_json, "pages": 1, "policy": "balanced", "note": "", "$files": [file_id]}
r = requests.post("https://api.skillsafe.ai/v1/app-api/estimate", headers={**H}, json=body)
print(r.json()["data"])
const keyJson = JSON.stringify({ title: "Quiz", questions: [{ q: "1", max: 2, rubric: [
  { line: 1, text: "Names the xylem", marks: 1 }, { line: 2, text: "Water enters by osmosis", marks: 1 }] }] });
const body = { task: "grade", key: keyJson, pages: 1, policy: "balanced", note: "", $files: [fileId] };
const r = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", { method: "POST",
  headers: { ...H, "Content-Type": "application/json" }, body: JSON.stringify(body) });
console.log((await r.json()).data);
body := []byte(`{"task":"grade","key":"{\"title\":\"Quiz\",\"questions\":[{\"q\":\"1\",\"max\":2,\"rubric\":[{\"line\":1,\"text\":\"Names the xylem\",\"marks\":1},{\"line\":2,\"text\":\"Water enters by osmosis\",\"marks\":1}]}]}","pages":1,"policy":"balanced","note":"","$files":["FILE_ID"]}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
  .header("Authorization", "Bearer YOUR_TOKEN").header("Content-Type", "application/json")
  .POST(HttpRequest.BodyPublishers.ofString(json)).build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Bearer YOUR_TOKEN"
req.body = { task: "grade", key: key_json, pages: 1, policy: "balanced", note: "", "$files" => [file_id] }.to_json
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN", "Content-Type: application/json"],
  CURLOPT_POSTFIELDS => json_encode(["task" => "grade", "key" => $keyJson, "pages" => 1, "policy" => "balanced", "note" => "", "$files" => [$fileId]])]);
echo curl_exec($ch);
var json = JsonSerializer.Serialize(new Dictionary<string, object> {
  ["task"] = "grade", ["key"] = keyJson, ["pages"] = 1, ["policy"] = "balanced", ["note"] = "", ["$files"] = new[] { fileId } });
var msg = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/estimate") { Content = new StringContent(json, Encoding.UTF8, "application/json") };
var res = await http.SendAsync(msg);

6. Run — POST /run, then poll GET /jobs/{id}

Send an Idempotency-Key: a retry with the same key never bills twice.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: grade-maya-2026-10-31-001" \
  -d '{"task":"grade","key":"{\"title\":\"Quiz\",\"questions\":[{\"q\":\"1\",\"max\":2,\"rubric\":[{\"line\":1,\"text\":\"Names the xylem\",\"marks\":1},{\"line\":2,\"text\":\"Water enters by osmosis\",\"marks\":1}]}]}","pages":1,"policy":"balanced","note":"","$files":["FILE_ID"]}'
body = {"task": "grade", "key": key_json, "pages": 1, "policy": "balanced", "note": "", "$files": [file_id]}
r = requests.post("https://api.skillsafe.ai/v1/app-api/run", headers={**H, 'Idempotency-Key': 'grade-maya-2026-10-31-001'}, json=body)
print(r.json()["data"])
const body = { task: "grade", key: keyJson, pages: 1, policy: "balanced", note: "", $files: [fileId] };
const r = await fetch("https://api.skillsafe.ai/v1/app-api/run", { method: "POST",
  headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": "grade-maya-2026-10-31-001" }, body: JSON.stringify(body) });
console.log((await r.json()).data);
body := []byte(`{"task":"grade","key":"{\"title\":\"Quiz\",\"questions\":[{\"q\":\"1\",\"max\":2,\"rubric\":[{\"line\":1,\"text\":\"Names the xylem\",\"marks\":1},{\"line\":2,\"text\":\"Water enters by osmosis\",\"marks\":1}]}]}","pages":1,"policy":"balanced","note":"","$files":["FILE_ID"]}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "grade-maya-2026-10-31-001")
res, _ := http.DefaultClient.Do(req)
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/run"))
  .header("Authorization", "Bearer YOUR_TOKEN").header("Content-Type", "application/json")
  .header("Idempotency-Key", "grade-maya-2026-10-31-001")
  .POST(HttpRequest.BodyPublishers.ofString(json)).build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/run")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Idempotency-Key"] = "grade-maya-2026-10-31-001"
req.body = { task: "grade", key: key_json, pages: 1, policy: "balanced", note: "", "$files" => [file_id] }.to_json
res = Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN", "Content-Type: application/json", "Idempotency-Key: grade-maya-2026-10-31-001"],
  CURLOPT_POSTFIELDS => json_encode(["task" => "grade", "key" => $keyJson, "pages" => 1, "policy" => "balanced", "note" => "", "$files" => [$fileId]])]);
echo curl_exec($ch);
var json = JsonSerializer.Serialize(new Dictionary<string, object> {
  ["task"] = "grade", ["key"] = keyJson, ["pages"] = 1, ["policy"] = "balanced", ["note"] = "", ["$files"] = new[] { fileId } });
var msg = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run") { Content = new StringContent(json, Encoding.UTF8, "application/json") };
msg.Headers.Add("Idempotency-Key", "grade-maya-2026-10-31-001");
var res = await http.SendAsync(msg);
curl -s https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID -H "Authorization: Bearer YOUR_TOKEN"
# repeat until data.status is "succeeded" or "failed"; the reply text is data.output.output
import time, json
while True:
    job = requests.get(f"https://api.skillsafe.ai/v1/app-api/jobs/{job_id}", headers=H).json()["data"]
    if job["status"] in ("succeeded", "failed"): break
    time.sleep(1.5)
grading = json.loads(job["output"]["output"])
let job;
do {
  await new Promise(r => setTimeout(r, 1500));
  job = (await (await fetch(`https://api.skillsafe.ai/v1/app-api/jobs/${jobId}`, { headers: H })).json()).data;
} while (job.status !== "succeeded" && job.status !== "failed");
const grading = JSON.parse(job.output.output);
for {
  req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/jobs/"+jobID, nil)
  req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
  // decode data.status; stop on succeeded or failed
  time.Sleep(1500 * time.Millisecond)
}
// GET https://api.skillsafe.ai/v1/app-api/jobs/{job_id} every 1.5 s until data.status is succeeded or failed
loop do
  job = JSON.parse(Net::HTTP.get(URI("https://api.skillsafe.ai/v1/app-api/jobs/#{job_id}"), { "Authorization" => "Bearer YOUR_TOKEN" }))["data"]
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
do { usleep(1500000);
  $ch = curl_init("https://api.skillsafe.ai/v1/app-api/jobs/$jobId");
  curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_TOKEN"]]);
  $job = json_decode(curl_exec($ch), true)["data"];
} while (!in_array($job["status"], ["succeeded", "failed"]));
string status;
do { await Task.Delay(1500);
  var j = JsonDocument.Parse(await http.GetStringAsync($"https://api.skillsafe.ai/v1/app-api/jobs/{jobId}"));
  status = j.RootElement.GetProperty("data").GetProperty("status").GetString();
} while (status != "succeeded" && status != "failed");

7. Or stream — POST /run-stream

curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" -d '{"task":"grade","key":"{\"title\":\"Quiz\",\"questions\":[{\"q\":\"1\",\"max\":2,\"rubric\":[{\"line\":1,\"text\":\"Names the xylem\",\"marks\":1},{\"line\":2,\"text\":\"Water enters by osmosis\",\"marks\":1}]}]}","pages":1,"policy":"balanced","note":"","$files":["FILE_ID"]}'
# events: job, delta (text), tick (seconds), done (the whole job), error
with requests.post("https://api.skillsafe.ai/v1/app-api/run-stream", headers={**H, "Accept": "text/event-stream"}, json=body, stream=True) as r:
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("event:") or line.startswith("data:"):
            print(line)
const res = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", { method: "POST",
  headers: { ...H, "Content-Type": "application/json", Accept: "text/event-stream" }, body: JSON.stringify(body) });
const reader = res.body.getReader(); const dec = new TextDecoder();
for (;;) { const { done, value } = await reader.read(); if (done) break; console.log(dec.decode(value)); }

Output contract

output.output is one JSON object (strip a Markdown fence if one appears):

{
  "status": "ok" | "not_answer_sheet" | "unreadable",
  "name_on_sheet": "Maya Rahman",
  "sheet_notes": "One page, clear photo, blue pen.",
  "answers": [
    {"q": "1", "legibility": "clear" | "partly_legible" | "illegible" | "blank",
     "read": "Photosynthesis is how plants make their own food...",
     "lines": [{"line": 1, "got": 1}, {"line": 2, "got": 1}],
     "marks": 2,
     "justification": "Line 1: ... Line 2: ... Line 3 missed: ...",
     "feedback": "Good definition. Add what goes in: carbon dioxide and water.",
     "needs_check": false, "check_reason": ""}
  ],
  "overall_feedback": "Two or three sentences to the student."
}

One answer per key question, in key order, including blank ones. Before using the marks: cap marks at the question's max and round to halves; ignore lines that are not in your rubric; flag a question whose marks differ from the sum of got; and treat needs_check, illegible and partly_legible as "a teacher must look". The page does all of this in /result.js (reconcile). It is a first pass from handwriting recognition and can misread words.