Practice tests
Have WorkRoad write a practice test or save your own questions, then take attempts and read the grades.
Practice tests
A practice test asks questions written from the user's study sets and notes. Having WorkRoad's AI write one with POST /tests is one AI use, and AI grading of its short answers is included. Taking a test is free, except that AI grading of short answers in a test saved with POST /tests/from-questions is one AI use per submit.
To save questions you wrote yourself, POST /tests/from-questions takes a name and 1-50 questions, each with a kind (multiple-choice, true-false, fill-blank or short-answer), a prompt, the answer, an explanation and a difficulty (recall, apply or analyse), and multiple choice questions their choices. That is the shape WorkRoad's own generator writes, and the test is ready at once, with no job and no AI use. A question that can't be graded as written returns 400 with details.index and details.reason, such as answer-not-a-choice. Choices are shuffled once when the test is saved.
POST /testswith a uuidrequestId, aname,sourceSetIdsorsourceNoteIds, the questionkinds(MCQ,TRUE_FALSE,SHORTorFILL_BLANK), alengthof 1-50 and an optionaltimerSecs. It answers 202 with thetestIdand the generation'sjobId. Mint therequestIdonce and keep it for retries: it becomes the job id, and sending it again with the same body returns the same test and counts one AI use. A usedrequestIdwith different sources,kinds,length,timerSecsormodelTierreturns 409idempotency_conflict.nameis not compared, so a replay under another name returns the original test with its original name. A refused start (402 without AI uses left, or 429) creates no test, so there is nothing to clean up and the 402 body has notestId.- Poll
GET /jobs/{jobId}orGET /tests/{testId}until the test'sstatusisready, waiting the seconds inRetry-Afterbetween job polls (3 for a test). A test that failed has the reason inerror. POST /tests/{testId}/attemptsstarts an attempt and returns itsattemptId. A test has one open attempt at a time. If the user already has one open, you get it back with status 200 andresumed: true. A test that isn't ready returns 409 withdetails.reasontest-not-ready.GET /attempts/{attemptId}returns the questions. Answer them by index as a string,"0"for the first question. For multiple choice, send the text of the choice. For true/false, send"true"or"false".PUT /attempts/{attemptId}/answerssaves the answers so far, replacing what was saved before. Past the time limit it stores nothing and returnsexpired: true. A key must be a question index without leading zeros, and a key past the last question returns 400 withdetails.reasonunknown-question.POST /attempts/{attemptId}/submitends the attempt, optionally with finalanswers. Submitting twice changes nothing and returnsalreadySubmitted: true.
{
"answers": {
"0": "Mitochondrion",
"1": "It keeps the cell's contents in and controls what enters."
}
}The answer key stays hidden until the attempt is submitted. While a test is generating, GET /tests/{testId} shows the prompts written so far in generating, without answers; once it is ready, the questions come only through an attempt. While an attempt is open every question has answer and explanation set to null and perQuestion is empty. Once it is submitted, GET /attempts/{attemptId} shows the correct answers and a verdict per question.
The time limit counts from the attempt's startedAt on the server's clock. deadlineAt is when it runs out, and expired turns true 15 seconds later, a grace period for a slow last request. After that, submitting grades the saved answers and ignores the ones in the body.
Multiple choice, true/false and fill-in answers are graded on submit. Short answers go to AI grading: gradingQueued is true, gradingJobId names the job, and phase stays grading until it ends. Grading never makes a submit fail. If AI grading can't start, because WorkRoad is busy or, for a test saved with POST /tests/from-questions, the month's AI uses are spent, the attempt is still submitted with gradingQueued: false, and its gradingStatus and phase read failed and grading_failed. The short answers then keep the verdict manual, and the user marks them in the app.
A wrong answer's explainRef asks the copilot to explain it: send it as context.activityRef with context.kind activity to POST /copilot/messages.
List practice tests
Newest first, with each test's best score and the attempt in progress. Questions are not listed.
Required scopes: tests:view
AuthorizationBearer <token>limit?integerPage size, 1-100.
1 <= value <= 10025cursor?|Opaque. Pass nextCursor from the previous page unchanged.
OK
application/json- response
data*array<>nextCursor*|Opaque. Pass as ?cursor= unchanged to fetch the next page. null on the last page.
curl -X GET 'https://workroad.app/api/v1/tests' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "data": [ { "id": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "name": "Cell biology quiz", "assisted": false, "status": "ready", "error": null, "createdAt": "2026-01-15T09:30:00.000Z", "questionCount": 2, "requestedLength": 2, "kinds": [ "MCQ", "SHORT" ], "timerSecs": 600, "attemptCount": 1, "bestRatio": 0.5, "bestAssistedRatio": null, "openAttemptId": null } ], "nextCursor": null}Get a practice test
The test's settings, how many questions of each kind it has, and its recent attempts. The questions themselves are read through an attempt. While the test is generating, generating lists the questions written so far without their answers.
Required scopes: tests:view
AuthorizationBearer <token>testId*stringPractice test id, scoped to the key's user.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidOK
application/json- response
data*curl -X GET 'https://workroad.app/api/v1/tests/497f6eca-6276-4993-bfeb-53cbbbba6f08' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "data": { "id": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "name": "Cell biology quiz", "assisted": false, "status": "ready", "error": null, "createdAt": "2026-01-15T09:30:00.000Z", "generationJobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "questionsVersion": 1, "questionCount": 2, "kindCounts": [ { "kind": "multiple-choice", "count": 1 }, { "kind": "short-answer", "count": 1 } ], "generating": null, "requestedLength": 2, "kinds": [ "MCQ", "SHORT" ], "timerSecs": 600, "modelTier": "scholar", "sourceSetIds": [], "sourceNoteIds": [ "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" ], "attempts": [ { "id": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "assisted": false, "score": { "num": 1, "den": 2 }, "gradingStatus": "graded", "questionsVersion": 1, "startedAt": "2026-01-15T09:30:00.000Z", "finishedAt": "2026-02-01T12:00:00.000Z", "gradedAt": "2026-02-01T12:00:00.000Z" } ] }}Create a practice test
Writes a practice test from study sets and notes, as the app's New test dialog does. The test starts as generating: poll GET /jobs/{jobId} or GET /tests/{testId} until its status is ready. Counts as one AI use, which also covers AI grading of its short answers. Send the same requestId again to retry safely: it returns the same test. A refused start leaves no test behind.
Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (GET /ai-allowance). If you can write this content yourself, write it and save it with POST /tests/from-questions, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work.
Required scopes: tests:generate
AuthorizationBearer <token>Idempotency-Key?stringA client-generated key (1 to 255 visible ASCII characters) that makes this POST safe to retry. A retry with the same key and body replays the original response; the same key with a different body returns 409 idempotency_conflict. Stored for 24 hours.
^[\x21-\x7E]{1,255}$1 <= length <= 255application/json- body
requestId*stringA uuid you mint for this start. It becomes the job id. Sending it again with the same sets, notes, kinds, length, timerSecs and modelTier returns the original test and is not counted twice. name is not compared, so a replay under another name still returns the original test with its original name. Any other change under a used requestId is 409 idempotency_conflict.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidname*stringThe test's name, 1-120 characters.
1 <= length <= 120sourceSetIds?array<>Study sets to write questions from, up to 10.
items <= 10[]sourceNoteIds?array<>Notes to write questions from, up to 10. Name at least one set or note, and they must have text.
items <= 10[]kinds*array<>The kinds of question to ask, at least one.
1 <= itemslength*integerHow many questions, 1-50.
1 <= value <= 50timerSecs?|A time limit in seconds, 60-7200, counted from the start of each attempt. Null or omitted for no limit.
nullmodelTier?stringswift, scholar (the default) or sage. sage needs a paid plan; a free user gets 400 with details.reason premium-only.
"swift""scholar""sage"Accepted
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/tests' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6", "name": "string", "kinds": [ "MCQ" ], "length": 1}'{ "data": { "testId": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" }}Create a practice test from your questions
Saves a practice test whose questions you wrote, in the shape WorkRoad's own generator writes them, and answers 201 with the test, ready to take: start it with POST /tests/{testId}/attempts. Nothing is generated, so it uses no AI. Multiple choice, true/false and fill-in questions are graded without AI too; short answers go to AI grading when an attempt is submitted, which counts as an AI use on each submit, so prefer the other kinds unless the user wants written answers. Read the material first (GET /notes/{noteId}, GET /sets/{setId}) and write questions it answers.
Use this to save content you wrote yourself. It uses none of the user's WorkRoad AI uses.
Required scopes: tests:create
AuthorizationBearer <token>Idempotency-Key?stringA client-generated key (1 to 255 visible ASCII characters) that makes this POST safe to retry. A retry with the same key and body replays the original response; the same key with a different body returns 409 idempotency_conflict. Stored for 24 hours.
^[\x21-\x7E]{1,255}$1 <= length <= 255application/json- body
name*stringThe test's name, 1-120 characters.
1 <= length <= 120questions*array<>1-50 questions in the order they are asked, written in the shape WorkRoad's own test generator writes them.
1 <= items <= 50timerSecs?|A time limit in seconds, 60-7200, counted from the start of each attempt. Null or omitted for no limit.
nullsourceSetIds?array<>The user's study sets the questions cover, up to 10. Optional; they label the test.
items <= 10[]sourceNoteIds?array<>The user's notes the questions cover, up to 10. Optional.
items <= 10[]Created
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/tests/from-questions' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "name": "string", "questions": [ { "kind": "multiple-choice", "prompt": "string", "answer": "string", "explanation": "string", "difficulty": "recall" } ]}'{ "data": { "id": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "name": "Cell biology quiz", "assisted": false, "status": "ready", "error": null, "createdAt": "2026-01-15T09:30:00.000Z", "generationJobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "questionsVersion": 1, "questionCount": 2, "kindCounts": [ { "kind": "multiple-choice", "count": 1 }, { "kind": "short-answer", "count": 1 } ], "generating": null, "requestedLength": 2, "kinds": [ "MCQ", "SHORT" ], "timerSecs": 600, "modelTier": "scholar", "sourceSetIds": [], "sourceNoteIds": [ "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" ], "attempts": [ { "id": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "assisted": false, "score": { "num": 1, "den": 2 }, "gradingStatus": "graded", "questionsVersion": 1, "startedAt": "2026-01-15T09:30:00.000Z", "finishedAt": "2026-02-01T12:00:00.000Z", "gradedAt": "2026-02-01T12:00:00.000Z" } ] }}Start a practice test attempt
Starts an attempt at a ready test, or returns the attempt the user already has open (status 200, resumed true). A test has at most one open attempt. The time limit, if any, counts from the attempt's start.
Required scopes: tests:attempt
AuthorizationBearer <token>testId*stringPractice test id, scoped to the key's user.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidIdempotency-Key?stringA client-generated key (1 to 255 visible ASCII characters) that makes this POST safe to retry. A retry with the same key and body replays the original response; the same key with a different body returns 409 idempotency_conflict. Stored for 24 hours.
^[\x21-\x7E]{1,255}$1 <= length <= 255The user's open attempt was resumed. The body has resumed: true.
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/tests/497f6eca-6276-4993-bfeb-53cbbbba6f08/attempts' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "data": { "attemptId": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "resumed": false }}Get a practice test attempt
The attempt's questions, the user's answers and, once submitted, the correct answers and a verdict for each question. While the attempt is open no question has an answer or explanation. After a submit, poll this until phase is no longer grading.
Required scopes: tests:view or tests:attempt
AuthorizationBearer <token>attemptId*stringPractice test attempt id, scoped to the key's user.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidOK
application/json- response
data*curl -X GET 'https://workroad.app/api/v1/attempts/497f6eca-6276-4993-bfeb-53cbbbba6f08' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "data": { "id": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b", "testId": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817", "phase": "open", "startedAt": "2026-01-15T09:30:00.000Z", "deadlineAt": "2026-01-15T09:40:00.000Z", "expired": false, "finishedAt": null, "score": null, "gradingStatus": "not_required", "questions": [ { "kind": "multiple-choice", "prompt": "Which organelle makes ATP?", "choices": [ "Nucleus", "Mitochondrion", "Ribosome" ], "difficulty": "recall", "answer": null, "explanation": null }, { "kind": "short-answer", "prompt": "Why do cells need a membrane?", "choices": null, "difficulty": "understanding", "answer": null, "explanation": null } ], "answers": [ "Mitochondrion", null ], "perQuestion": [] }}Save practice test answers
Stores the answers so far without submitting, as the app's autosave does. Once the time limit has passed nothing is stored and expired is true; submit the attempt to grade what was saved.
Required scopes: tests:attempt
AuthorizationBearer <token>attemptId*stringPractice test attempt id, scoped to the key's user.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidapplication/json- body
answers*Every answer so far, keyed by question index as a string ("0" is the first question), each up to 4000 characters. Only indexes of the attempt's questions, without leading zeros. Replaces the answers saved before, so send them all. For a multiple choice question send the choice's text, and for a true/false question "true" or "false".
OK
application/json- response
data*curl -X PUT 'https://workroad.app/api/v1/attempts/497f6eca-6276-4993-bfeb-53cbbbba6f08/answers' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "answers": { "property1": "string", "property2": "string" }}'{ "data": { "saved": true, "expired": false }}Submit a practice test attempt
Ends the attempt and grades it. Multiple choice, true/false and fill-in answers are graded at once. Short answers go to AI grading, and gradingJobId is its job. On a test WorkRoad's AI wrote (POST /tests), grading is part of that test's AI use; on a test saved with POST /tests/from-questions, it counts as one AI use. Grading never refuses a submit: when AI grading can't start, for example because the month's AI uses are spent on a POST /tests/from-questions test, the attempt is still submitted with gradingQueued false and gradingStatus failed, and the user marks the short answers themselves in the app. Submitting again changes nothing and returns alreadySubmitted true.
Runs WorkRoad's AI to grade short answers in a test you saved yourself, which counts toward the user's WorkRoad AI uses (GET /ai-allowance).
Required scopes: tests:attempt
AuthorizationBearer <token>attemptId*stringPractice test attempt id, scoped to the key's user.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidIdempotency-Key?stringA client-generated key (1 to 255 visible ASCII characters) that makes this POST safe to retry. A retry with the same key and body replays the original response; the same key with a different body returns 409 idempotency_conflict. Stored for 24 hours.
^[\x21-\x7E]{1,255}$1 <= length <= 255application/json- body
answers?Final answers, keyed as in PUT /attempts/{attemptId}/answers. Each replaces the saved answer for its index. Ignored once the time limit has passed; the saved answers are graded instead.
{}OK
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/attempts/497f6eca-6276-4993-bfeb-53cbbbba6f08/submit' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{}'{ "data": { "alreadySubmitted": false, "gradingQueued": true, "gradingJobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" }}