# Practice tests

> Have WorkRoad write a practice test or save your own questions, then take attempts and read the grades.

Source: https://workroad.app/docs/api/tests

## 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.

1. `POST /tests` with a uuid `requestId`, a `name`, `sourceSetIds` or `sourceNoteIds`, the question `kinds` (`MCQ`, `TRUE_FALSE`, `SHORT` or `FILL_BLANK`), a `length` of 1-50 and an optional `timerSecs`. It answers 202 with the `testId` and the generation's `jobId`. Mint the `requestId` once 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 used `requestId` with different sources, `kinds`, `length`, `timerSecs` or `modelTier` returns 409 `idempotency_conflict`. `name` is 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 no `testId`.
2. Poll `GET /jobs/{jobId}` or `GET /tests/{testId}` until the test's `status` is `ready`, waiting the seconds in `Retry-After` between job polls (3 for a test). A test that failed has the reason in `error`.
3. `POST /tests/{testId}/attempts` starts an attempt and returns its `attemptId`. A test has one open attempt at a time. If the user already has one open, you get it back with status 200 and `resumed: true`. A test that isn't ready returns 409 with `details.reason` `test-not-ready`.
4. `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"`.
5. `PUT /attempts/{attemptId}/answers` saves the answers so far, replacing what was saved before. Past the time limit it stores nothing and returns `expired: true`. A key must be a question index without leading zeros, and a key past the last question returns 400 with `details.reason` `unknown-question`.
6. `POST /attempts/{attemptId}/submit` ends the attempt, optionally with final `answers`. Submitting twice changes nothing and returns `alreadySubmitted: true`.

```json
{
   "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

`GET https://workroad.app/api/v1/tests`

Newest first, with each test's best score and the attempt in progress. Questions are not listed.

- MCP tool: `listTests` on `https://workroad.app/api/mcp`
- Required scopes: tests:view
- Test mode: `GET https://workroad.app/api/test/v1/tests` checks the request and returns the example response without changing anything.

### Query parameters

- `limit`: integer, 1 to 100, default `25`. Page size, 1-100.
- `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged.

### Response

`200` OK

- `data`: array of objects, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `assisted`: boolean, required. True for a test made with the copilot's help.
  - `status`: one of `"draft"`, `"generating"`, `"ready"`, `"failed"`, required. `generating` until the questions are written, then `ready`. `failed` when generation failed or could not start; `error` says why.
  - `error`: string or null, required. Why generation failed.
  - `createdAt`: string (date-time), required. When the test was created.
  - `questionCount`: integer, required. Questions written so far.
  - `requestedLength`: integer, required. Questions asked for.
  - `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required
  - `timerSecs`: integer or null, required. The time limit, or null.
  - `attemptCount`: integer, required. Submitted attempts with a score.
  - `bestRatio`: number or null, required. The best score of an unassisted attempt, 0-1. Null without one.
  - `bestAssistedRatio`: number or null, required. The best score of an assisted attempt, 0-1. Null without one.
  - `openAttemptId`: string (uuid) or null, required. The attempt in progress, which `POST /tests/{testId}/attempts` resumes. Null when there is none.
- `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page.

#### Example

```json
{
  "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
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Get a practice test

`GET https://workroad.app/api/v1/tests/{testId}`

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.

- MCP tool: `getTest` on `https://workroad.app/api/mcp`
- Required scopes: tests:view
- Test mode: `GET https://workroad.app/api/test/v1/tests/{testId}` checks the request and returns the example response without changing anything.

### Path parameters

- `testId`: string (uuid), required. Practice test id, scoped to the key's user.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `assisted`: boolean, required
  - `status`: one of `"draft"`, `"generating"`, `"ready"`, `"failed"`, required. `generating` until the questions are written, then `ready`. `failed` when generation failed or could not start; `error` says why.
  - `error`: string or null, required. Why generation failed.
  - `createdAt`: string (date-time), required. When the test was created.
  - `generationJobId`: string or null, required. The latest generation job. Poll `GET /jobs/{jobId}` while `status` is `generating`.
  - `questionsVersion`: integer, required. Goes up each time the questions are generated again.
  - `questionCount`: integer, required
  - `kindCounts`: array of objects, required. How many questions of each kind the test has.
    - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI.
    - `count`: integer, required
  - `generating`: object or null, required. While `status` is `generating`, the questions written so far, without answers. Null otherwise.
    - `total`: integer, required. Questions asked for.
    - `questions`: array of objects, required
      - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI.
      - `prompt`: string, required
      - `difficulty`: string, required
      - `choiceCount`: integer, required
  - `requestedLength`: integer, required
  - `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required
  - `timerSecs`: integer or null, required. The time limit, or null.
  - `modelTier`: string, required
  - `sourceSetIds`: array of string, required
  - `sourceNoteIds`: array of string, required
  - `attempts`: array of objects, required. The 25 most recent attempts, newest first.
    - `id`: string (uuid), required
    - `assisted`: boolean, required
    - `score`: object or null, required. Correct answers out of all questions. Null until grading ends.
      - `num`: integer, required
      - `den`: integer, required
    - `gradingStatus`: one of `"not_required"`, `"pending"`, `"graded"`, `"failed"`, required. `not_required` when every answer was graded on submit, `pending` while AI grades the short answers, `graded` once it has, and `failed` when AI grading could not run. The app then lets the user mark those answers themselves.
    - `questionsVersion`: integer, required. The version of the questions the attempt answered.
    - `startedAt`: string (date-time), required. When the attempt started.
    - `finishedAt`: string (date-time) or null, required. When it was submitted. Null while it is open.
    - `gradedAt`: string (date-time) or null, required. When grading ended.

#### Example

```json
{
  "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"
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No test with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Create a practice test

`POST https://workroad.app/api/v1/tests`

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.

- MCP tool: `createTest` on `https://workroad.app/api/mcp`
- Required scopes: tests:generate
- Test mode: `POST https://workroad.app/api/test/v1/tests` checks the request and returns the example response without changing anything.
- Next: poll `getJob` with the `jobId` until `succeeded`, then `startTestAttempt`.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `requestId`: string (uuid), required. A 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`.
- `name`: string, required, 1-120 characters. The test's name, 1-120 characters.
- `sourceSetIds`: array of string, up to 10 items, default `[]`. Study sets to write questions from, up to 10.
- `sourceNoteIds`: array of string, up to 10 items, default `[]`. Notes to write questions from, up to 10. Name at least one set or note, and they must have text.
- `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required, at least 1 item. The kinds of question to ask, at least one.
- `length`: integer, required, 1 to 50. How many questions, 1-50.
- `timerSecs`: integer or null, 60 to 7200, default `null`. A time limit in seconds, 60-7200, counted from the start of each attempt. Null or omitted for no limit.
- `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` (the default) or `sage`. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`.

### Response

`202` Accepted

- `data`: object, required
  - `testId`: string (uuid), required. The new test. Its `status` is `generating`.
  - `jobId`: string (uuid), required. The generation job. Poll `GET /jobs/{jobId}` until it ends.

#### Example

```json
{
  "data": {
    "testId": "6e5d4c3b-2a19-4f08-8e7d-6c5b4a392817",
    "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request`: the body fails validation, or `details.field` and `details.reason` say why: `sources` `no-sources` (name a set or a note), `sources` `empty-sources` (they have no text), `sourceSetIds` `unknown-set` or `sourceNoteIds` `unknown-note` (a set or note in the body isn't the user's), or `modelTier` `premium-only`.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `idempotency_conflict`: this `requestId` already started a test from other sets, notes, question kinds, length, time limit or `modelTier`. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
- `503`: WorkRoad can't start background work right now. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice.

## Create a practice test from your questions

`POST https://workroad.app/api/v1/tests/from-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.

- MCP tool: `createTestFromQuestions` on `https://workroad.app/api/mcp`
- Required scopes: tests:create
- Test mode: `POST https://workroad.app/api/test/v1/tests/from-questions` checks the request and returns the example response without changing anything.
- Next: start it with `startTestAttempt`, or give the user the test's name to take it in the app.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `name`: string, required, 1-120 characters. The test's name, 1-120 characters.
- `questions`: array of objects, required, 1-50 items. 1-50 questions in the order they are asked, written in the shape WorkRoad's own test generator writes them.
  - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. `multiple-choice`, `true-false`, `fill-blank` or `short-answer`. Short answers are graded by WorkRoad's AI when the attempt is submitted, which counts as an AI use; the other kinds are graded without AI.
  - `prompt`: string, required, 1-2000 characters. The question as the student reads it.
  - `choices`: array of string, 2-8 items. Multiple choice only: 8 or fewer distinct options, one of them exactly `answer`. WorkRoad shuffles them once when it saves the test.
  - `answer`: string, required, 1-2000 characters. The correct answer: the right choice's exact text, `true` or `false`, the word or phrase that fills the blank, or a model answer for a short answer.
  - `explanation`: string, required, 1-4000 characters. Why the answer is right, shown after the attempt is submitted.
  - `difficulty`: one of `"recall"`, `"apply"`, `"analyse"`, required. `recall`, `apply` or `analyse`.
- `timerSecs`: integer or null, 60 to 7200, default `null`. A time limit in seconds, 60-7200, counted from the start of each attempt. Null or omitted for no limit.
- `sourceSetIds`: array of string, up to 10 items, default `[]`. The user's study sets the questions cover, up to 10. Optional; they label the test.
- `sourceNoteIds`: array of string, up to 10 items, default `[]`. The user's notes the questions cover, up to 10. Optional.

### Response

`201` Created

- `data`: object, required
  - `id`: string (uuid), required
  - `name`: string, required
  - `assisted`: boolean, required
  - `status`: one of `"draft"`, `"generating"`, `"ready"`, `"failed"`, required. `generating` until the questions are written, then `ready`. `failed` when generation failed or could not start; `error` says why.
  - `error`: string or null, required. Why generation failed.
  - `createdAt`: string (date-time), required. When the test was created.
  - `generationJobId`: string or null, required. The latest generation job. Poll `GET /jobs/{jobId}` while `status` is `generating`.
  - `questionsVersion`: integer, required. Goes up each time the questions are generated again.
  - `questionCount`: integer, required
  - `kindCounts`: array of objects, required. How many questions of each kind the test has.
    - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI.
    - `count`: integer, required
  - `generating`: object or null, required. While `status` is `generating`, the questions written so far, without answers. Null otherwise.
    - `total`: integer, required. Questions asked for.
    - `questions`: array of objects, required
      - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI.
      - `prompt`: string, required
      - `difficulty`: string, required
      - `choiceCount`: integer, required
  - `requestedLength`: integer, required
  - `kinds`: array of one of `"MCQ"`, `"TRUE_FALSE"`, `"SHORT"`, `"FILL_BLANK"`, required
  - `timerSecs`: integer or null, required. The time limit, or null.
  - `modelTier`: string, required
  - `sourceSetIds`: array of string, required
  - `sourceNoteIds`: array of string, required
  - `attempts`: array of objects, required. The 25 most recent attempts, newest first.
    - `id`: string (uuid), required
    - `assisted`: boolean, required
    - `score`: object or null, required. Correct answers out of all questions. Null until grading ends.
      - `num`: integer, required
      - `den`: integer, required
    - `gradingStatus`: one of `"not_required"`, `"pending"`, `"graded"`, `"failed"`, required. `not_required` when every answer was graded on submit, `pending` while AI grades the short answers, `graded` once it has, and `failed` when AI grading could not run. The app then lets the user mark those answers themselves.
    - `questionsVersion`: integer, required. The version of the questions the attempt answered.
    - `startedAt`: string (date-time), required. When the attempt started.
    - `finishedAt`: string (date-time) or null, required. When it was submitted. Null while it is open.
    - `gradedAt`: string (date-time) or null, required. When grading ended.

#### Example

```json
{
  "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"
      }
    ]
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request`: the body fails validation, or `details.field` is `questions`, `details.index` the question and `details.reason` why it can't be graded as written: `choices-missing`, `choices-unexpected`, `choice-empty`, `choices-duplicate`, `answer-not-a-choice` or `true-false-answer`. Also `sourceSetIds` `unknown-set` and `sourceNoteIds` `unknown-note`.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Start a practice test attempt

`POST https://workroad.app/api/v1/tests/{testId}/attempts`

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.

- MCP tool: `startTestAttempt` on `https://workroad.app/api/mcp`
- Required scopes: tests:attempt
- Test mode: `POST https://workroad.app/api/test/v1/tests/{testId}/attempts` checks the request and returns the example response without changing anything.
- Next: save answers with `saveTestAnswers`, then `submitTestAttempt`.

### Path parameters

- `testId`: string (uuid), required. Practice test id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Response

`201` Created

`200` The user's open attempt was resumed. The body has `resumed: true`.

- `data`: object, required
  - `attemptId`: string (uuid), required
  - `resumed`: boolean, required. True when the user's open attempt was returned instead of a new one.

#### Example

```json
{
  "data": {
    "attemptId": "1f2e3d4c-5b6a-4798-8a7b-6c5d4e3f2a1b",
    "resumed": false
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No test with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason` `test-not-ready`: the test is still generating, or it failed. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Get a practice test attempt

`GET https://workroad.app/api/v1/attempts/{attemptId}`

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`.

- MCP tool: `getTestAttempt` on `https://workroad.app/api/mcp`
- Required scopes: tests:view or tests:attempt
- Test mode: `GET https://workroad.app/api/test/v1/attempts/{attemptId}` checks the request and returns the example response without changing anything.

### Path parameters

- `attemptId`: string (uuid), required. Practice test attempt id, scoped to the key's user.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `testId`: string (uuid), required
  - `phase`: one of `"open"`, `"grading"`, `"grading_failed"`, `"graded"`, required. `open` until submitted, `grading` while AI grades short answers, `grading_failed` when AI grading could not run, and `graded` once every answer has a verdict.
  - `startedAt`: string (date-time), required. When the attempt started. The time limit counts from here.
  - `deadlineAt`: string (date-time) or null, required. When the time limit runs out, while the attempt is open. Null without a limit or once submitted.
  - `expired`: boolean, required. True 15 seconds after `deadlineAt`, while the attempt is open. Saved answers no longer change; submit to grade them.
  - `finishedAt`: string (date-time) or null, required. When it was submitted. Null while it is open.
  - `score`: object or null, required. Correct answers out of all questions. Null until grading ends.
    - `num`: integer, required
    - `den`: integer, required
  - `gradingStatus`: one of `"not_required"`, `"pending"`, `"graded"`, `"failed"`, required. `not_required` when every answer was graded on submit, `pending` while AI grades the short answers, `graded` once it has, and `failed` when AI grading could not run. The app then lets the user mark those answers themselves.
  - `questions`: array of objects, required. The questions as they were when the attempt started, in order.
    - `kind`: one of `"multiple-choice"`, `"short-answer"`, `"true-false"`, `"fill-blank"`, required. The question's kind. `short-answer` questions are graded by AI.
    - `prompt`: string, required
    - `choices`: array of string or null, required. The choices of a multiple choice question. Null otherwise.
    - `difficulty`: string, required
    - `answer`: string or null, required. The correct answer. Null until the attempt is submitted.
    - `explanation`: string or null, required. Why it is correct. Null until the attempt is submitted.
  - `answers`: array of string or null, required. The user's answer to each question, aligned with `questions`. Null where there is none.
  - `perQuestion`: array of objects, required. The verdict for each question. Empty until the attempt is submitted.
    - `questionIndex`: integer, required
    - `verdict`: one of `"correct"`, `"wrong"`, `"manual"`, required. `manual` while a short answer waits for AI grading or for the user to mark it.
    - `feedback`: string or null, required. The grader's comment.
    - `explainRef`: string or null, required. For a wrong answer, a reference to ask the copilot about it: send it as `context.activityRef` with `context.kind` `activity` to `POST /copilot/messages`.

#### Example

```json
{
  "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": []
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No test attempt with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Save practice test answers

`PUT https://workroad.app/api/v1/attempts/{attemptId}/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.

- MCP tool: `saveTestAnswers` on `https://workroad.app/api/mcp`
- Required scopes: tests:attempt
- Test mode: `PUT https://workroad.app/api/test/v1/attempts/{attemptId}/answers` checks the request and returns the example response without changing anything.

### Path parameters

- `attemptId`: string (uuid), required. Practice test attempt id, scoped to the key's user.

### Request body

JSON.

- `answers`: object of string, required. 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"`.

### Response

`200` OK

- `data`: object, required
  - `saved`: boolean, required. True when the answers were stored.
  - `expired`: boolean, required. True when the time limit had passed, so nothing was stored.

#### Example

```json
{
  "data": {
    "saved": true,
    "expired": false
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request` with `details.field` `answers` and `details.reason` `unknown-question`: a key is past the attempt's last question. `details.keys` lists those keys. Nothing is stored.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No test attempt with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict` with `details.reason` `already-submitted`: the attempt was submitted, so its answers can't change.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Submit a practice test attempt

`POST https://workroad.app/api/v1/attempts/{attemptId}/submit`

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`).

- MCP tool: `submitTestAttempt` on `https://workroad.app/api/mcp`
- Required scopes: tests:attempt
- Test mode: `POST https://workroad.app/api/test/v1/attempts/{attemptId}/submit` checks the request and returns the example response without changing anything.
- Next: poll `getTestAttempt` until it is graded.

### Path parameters

- `attemptId`: string (uuid), required. Practice test attempt id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `answers`: object of string, default `{}`. 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.

### Response

`200` OK

- `data`: object, required
  - `alreadySubmitted`: boolean, required. True when the attempt was submitted before. Nothing changed.
  - `gradingQueued`: boolean, required. True when short answers went to AI grading. False when every answer was graded on submit, or when AI grading could not start; then `gradingStatus` is `failed`.
  - `gradingJobId`: string or null, required. The grading job while `gradingQueued` is true. Poll it or the attempt.

#### Example

```json
{
  "data": {
    "alreadySubmitted": false,
    "gradingQueued": true,
    "gradingJobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request` with `details.field` `answers` and `details.reason` `unknown-question`: a key is past the attempt's last question. `details.keys` lists those keys. Nothing is stored. Also `invalid_request` with `details.reason` `empty` when the attempt has no questions to grade.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No test attempt with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: `rate_limited` with `details.reason` `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing is submitted; retry after `Retry-After` seconds. The user's own AI rate limit never refuses a submit: it leaves the short answers for the user to mark, like a spent allowance. Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
