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
| HTTP | code | meaning |
|---|---|---|
| 400 | validation_error | Body is not a JSON object, or a field has the wrong type. |
| 401 | unauthorized | Missing, expired or guest token on a metered call — sign in for a personal token. |
| 402 | insufficient_credits | Balance below the run's minimum. Top up at skillsafe.ai. |
| 404 | not_found | A $files id that does not exist or belongs to another session. |
| 429 | rate_limited | Slow 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
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | Always "grade". |
key | string | yes | The 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. |
pages | number | no | How many photos are attached. |
policy | string | no | "balanced" (default), "strict" or "lenient" (half marks for a partly made point). |
note | string | no | A marking instruction: "ignore spelling", "Q3 needs two examples for full marks". |
$files | array | yes | The 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.