> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crazygoldfish.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Evaluate an assignment with the API

> Grade a class assignment with the API: extract questions, add an answer key or rubric, then mark answers sent as documents or audio.

Assignment Evaluation reads an assignment's question paper, learns the teacher's answer key or rubric, then marks each student's answer and writes feedback. Students can answer on paper, which you upload as a PDF or photos, or out loud, which you send as a link to an MP3.

This guide is for developers. For a non-technical overview, see [Assignment Evaluation features](/products/assignment/features) and the [workflow](/products/assignment/workflow).

| Step | You call                                                  | You get back                    |
| ---- | --------------------------------------------------------- | ------------------------------- |
| 1    | Create an assignment                                      | An assignment `id`              |
| 2    | Upload the question paper, then submit it                 | Every question with its marks   |
| 3    | Add the answer key or a rubric, then submit it            | A model answer per question     |
| 4    | Create an answer sheet for a student                      | An answer sheet `id`            |
| 5    | Submit the student's answer as documents or an audio link | An evaluation in progress       |
| 6    | Get the score                                             | Marks and feedback per question |

<Info>
  Steps 2, 3, and 5 start a background job. Each one takes two calls: first the upload, then a separate call with `isFinal` set to the text `true`. Wait until `status` is `2` (Completed) before the next step. The `wait_for` helper below does this for you.
</Info>

## Set up

You need an access token. If you don't have one, follow the [Quickstart](/api-reference/quickstart).

Progress is reported as an integer `status`: `0` Open, `1` In Progress, `2` Completed, `3` Failed.

<CodeGroup>
  ```python Python theme={null}
  import time

  import requests

  BASE_URL = "https://api.crazygoldfish.com"
  HEADERS = {"Authorization": "Bearer YOUR_ACCESS_TOKEN"}

  COMPLETED, FAILED = 2, 3


  def wait_for(path, pick=lambda body: body["status"]):
      """Check the status every 5 seconds until it's Completed."""
      for _ in range(120):  # Give up after 10 minutes
          response = requests.get(BASE_URL + path, headers=HEADERS)
          response.raise_for_status()
          body = response.json()
          status = pick(body)
          if status == COMPLETED:
              return body
          if status == FAILED:
              raise RuntimeError(f"{path} failed")
          time.sleep(5)
      raise TimeoutError(f"{path} took longer than 10 minutes")
  ```

  ```javascript JavaScript theme={null}
  const BASE_URL = "https://api.crazygoldfish.com";
  const HEADERS = { Authorization: "Bearer YOUR_ACCESS_TOKEN" };

  const COMPLETED = 2;
  const FAILED = 3;

  // Check the status every 5 seconds until it's Completed
  async function waitFor(path, pick = (body) => body.status) {
    for (let i = 0; i < 120; i++) { // Give up after 10 minutes
      const response = await fetch(BASE_URL + path, { headers: HEADERS });
      if (!response.ok) throw new Error(await response.text());
      const body = await response.json();
      const status = pick(body);
      if (status === COMPLETED) return body;
      if (status === FAILED) throw new Error(`${path} failed`);
      await new Promise((resolve) => setTimeout(resolve, 5000));
    }
    throw new Error(`${path} took longer than 10 minutes`);
  }
  ```
</CodeGroup>

<Warning>
  Every path ends with a slash. Without it, the API redirects the request and a `POST` or `PATCH` loses its body.
</Warning>

## Step 1: Create the assignment

Send the assignment's name, and your own IDs for the class. You get back the `id` that every later call uses.

<CodeGroup>
  ```python Python theme={null}
  response = requests.post(
      BASE_URL + "/assignment-evaluation/v1/assignment/",
      headers=HEADERS,
      json={
          "assignmentName": "Unit 3 English essay",
          "grade": "7",
          "section": "A",
          "courseId": "300",
      },
  )
  response.raise_for_status()
  assignment_id = response.json()["id"]
  assignment_path = f"/assignment-evaluation/v1/assignment/{assignment_id}/"
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(BASE_URL + "/assignment-evaluation/v1/assignment/", {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({
      assignmentName: "Unit 3 English essay",
      grade: "7",
      section: "A",
      courseId: "300",
    }),
  });
  if (!response.ok) throw new Error(await response.text());
  const assignmentId = (await response.json()).id;
  const assignmentPath = `/assignment-evaluation/v1/assignment/${assignmentId}/`;
  ```
</CodeGroup>

<Accordion title="What you get back">
  ```json theme={null}
  {
    "id": "3c9e1f2a-7b4d-4a8e-9f1c-2d5e6a7b8c9d",
    "documents": []
  }
  ```

  `grade` is passed to the AI as the student level, so send it when you know it.
</Accordion>

## Step 2: Upload the question paper

Upload the paper, then send `isFinal` in a second call to start extraction. Wait for the assignment's `status` to reach `2`, then read the questions.

<CodeGroup>
  ```python Python theme={null}
  with open("question-paper.pdf", "rb") as paper:
      requests.patch(
          BASE_URL + assignment_path,
          headers=HEADERS,
          files={"documents": ("question-paper.pdf", paper, "application/pdf")},
      ).raise_for_status()

  requests.patch(
      BASE_URL + assignment_path, headers=HEADERS, data={"isFinal": "true"}
  ).raise_for_status()

  wait_for(assignment_path)

  questions = requests.get(
      BASE_URL + assignment_path + "questions/", headers=HEADERS
  ).json()
  ```

  ```javascript JavaScript theme={null}
  const paper = new FormData();
  paper.append("documents", questionPaperFile, "question-paper.pdf");
  await fetch(BASE_URL + assignmentPath, {
    method: "PATCH",
    headers: HEADERS,
    body: paper,
  });

  const submitPaper = new FormData();
  submitPaper.append("isFinal", "true");
  await fetch(BASE_URL + assignmentPath, {
    method: "PATCH",
    headers: HEADERS,
    body: submitPaper,
  });

  await waitFor(assignmentPath);

  const questions = await (
    await fetch(BASE_URL + assignmentPath + "questions/", { headers: HEADERS })
  ).json();
  ```
</CodeGroup>

<Tip>
  Photos work too: send several `documents` parts with `image/jpeg` or `image/png`, in one call or across several. One PDF per assignment, and never a PDF and photos together.
</Tip>

<Accordion title="What you get back">
  ```json theme={null}
  {
    "id": "3c9e1f2a-7b4d-4a8e-9f1c-2d5e6a7b8c9d",
    "assignmentName": "Unit 3 English essay",
    "instruction": "Answer both parts in your own words.",
    "marks": 2.0,
    "grade": "7",
    "courseId": "300",
    "parentQuestions": [
      {
        "id": "cabb699a-ef46-447c-85d2-f320d83f9745",
        "questionNumber": "1",
        "text": "Read the passage and answer the questions.",
        "marks": 0.0,
        "questions": [
          {
            "id": "2edd8bfd-3616-4899-95e9-98d67489ca11",
            "questionNumber": "a",
            "text": "Why does the narrator return to the village?",
            "marks": 1.0,
            "diagramDescription": null
          }
        ]
      }
    ]
  }
  ```

  A `parentQuestions` entry is a group. Shared material, such as a passage, sits in its `text`, and the questions a student answers sit in `questions`.
</Accordion>

## Step 3: Add the answer key or a rubric

Create the model answer sheet, then give the AI something to mark against. Use the teacher's answer key for questions with a right answer, and a rubric for open-ended work. Audio answers are always scored against a rubric.

<Tabs>
  <Tab title="Answer key">
    <CodeGroup>
      ```python Python theme={null}
      response = requests.post(
          BASE_URL + assignment_path + "model-answer/", headers=HEADERS
      )
      response.raise_for_status()
      model_answer_path = (
          assignment_path + f"model-answer/{response.json()['id']}/"
      )

      with open("answer-key.pdf", "rb") as key:
          requests.patch(
              BASE_URL + model_answer_path,
              headers=HEADERS,
              files={"documents": ("answer-key.pdf", key, "application/pdf")},
          ).raise_for_status()

      requests.patch(
          BASE_URL + model_answer_path, headers=HEADERS, data={"isFinal": "true"}
      ).raise_for_status()

      wait_for(assignment_path + "model-answer/")
      ```

      ```javascript JavaScript theme={null}
      const created = await fetch(BASE_URL + assignmentPath + "model-answer/", {
        method: "POST",
        headers: HEADERS,
      });
      if (!created.ok) throw new Error(await created.text());
      const modelAnswerPath =
        `${assignmentPath}model-answer/${(await created.json()).id}/`;

      const key = new FormData();
      key.append("documents", answerKeyFile, "answer-key.pdf");
      await fetch(BASE_URL + modelAnswerPath, {
        method: "PATCH",
        headers: HEADERS,
        body: key,
      });

      const submitKey = new FormData();
      submitKey.append("isFinal", "true");
      await fetch(BASE_URL + modelAnswerPath, {
        method: "PATCH",
        headers: HEADERS,
        body: submitKey,
      });

      await waitFor(assignmentPath + "model-answer/");
      ```
    </CodeGroup>
  </Tab>

  <Tab title="Rubric">
    <CodeGroup>
      ```python Python theme={null}
      response = requests.post(
          BASE_URL + assignment_path + "model-answer/", headers=HEADERS
      )
      response.raise_for_status()
      model_answer_path = (
          assignment_path + f"model-answer/{response.json()['id']}/"
      )

      requests.patch(
          BASE_URL + model_answer_path,
          headers=HEADERS,
          data={"rubricId": "2b973c83-2830-4712-87b7-64a8c0a99035"},
      ).raise_for_status()

      requests.patch(
          BASE_URL + model_answer_path, headers=HEADERS, data={"isFinal": "true"}
      ).raise_for_status()

      wait_for(assignment_path + "model-answer/")
      ```

      ```javascript JavaScript theme={null}
      const created = await fetch(BASE_URL + assignmentPath + "model-answer/", {
        method: "POST",
        headers: HEADERS,
      });
      if (!created.ok) throw new Error(await created.text());
      const modelAnswerPath =
        `${assignmentPath}model-answer/${(await created.json()).id}/`;

      const rubric = new FormData();
      rubric.append("rubricId", "2b973c83-2830-4712-87b7-64a8c0a99035");
      await fetch(BASE_URL + modelAnswerPath, {
        method: "PATCH",
        headers: HEADERS,
        body: rubric,
      });

      const submitRubric = new FormData();
      submitRubric.append("isFinal", "true");
      await fetch(BASE_URL + modelAnswerPath, {
        method: "PATCH",
        headers: HEADERS,
        body: submitRubric,
      });

      await waitFor(assignmentPath + "model-answer/");
      ```
    </CodeGroup>

    Create the rubric once with [Create a rubric](/api-reference/endpoint/rubrics/v1-rubric) and reuse its `id` across assignments.
  </Tab>
</Tabs>

<Warning>
  Send only one of `documents`, `rubricId`, or `isFinal` per call. Two together return `400`. Send `isFinal` as the text `true`, not as a JSON boolean.
</Warning>

<Accordion title="What you get back">
  ```json theme={null}
  {
    "id": "3b27e0be-f9e8-4c4d-977b-b315bfb292ab",
    "documents": [
      {
        "id": "4a7c1e90-2b6d-4f31-9c58-0d7e3f2a1b46",
        "status": 2,
        "pageNo": null,
        "uploadTime": "2025-04-06T09:31:02.118422Z",
        "url": "https://<storage-host>/documents/assignment-evaluation/unit-3-answer-key.pdf",
        "fileType": 1,
        "audio_link": null
      }
    ],
    "rubric": {},
    "status": 2
  }
  ```

  Once `status` is `2`, [Get extracted assignment model answers](/api-reference/endpoint/assignments/v1-assignment-model-answer-id-get) shows the model answer and step-wise marks for every question.
</Accordion>

## Step 4: Create an answer sheet for the student

One answer sheet holds one student's answer. Send your own `studentId` and the student's name.

<CodeGroup>
  ```python Python theme={null}
  student_id = "STU-10432"

  response = requests.post(
      BASE_URL + assignment_path + "answer-sheet/",
      headers=HEADERS,
      json={"studentId": student_id, "name": "Aarav Sharma"},
  )
  response.raise_for_status()
  sheet_id = response.json()["id"]
  sheet_path = assignment_path + f"answer-sheet/{sheet_id}/"
  ```

  ```javascript JavaScript theme={null}
  const studentId = "STU-10432";

  const sheet = await fetch(BASE_URL + assignmentPath + "answer-sheet/", {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({ studentId, name: "Aarav Sharma" }),
  });
  if (!sheet.ok) throw new Error(await sheet.text());
  const sheetId = (await sheet.json()).id;
  const sheetPath = `${assignmentPath}answer-sheet/${sheetId}/`;
  ```
</CodeGroup>

<Accordion title="What you get back">
  ```json theme={null}
  {
    "id": "6e1d2c3b-4a5f-4e6d-8c7b-9a0f1e2d3c4b",
    "status": 0,
    "batchId": null,
    "sessionId": null,
    "studentId": "STU-10432",
    "name": "Aarav Sharma",
    "teacherId": null,
    "documents": []
  }
  ```

  You can also send `batchId`, `sessionId`, and `teacherId`. CrazyGoldFish stores them and returns them unchanged, and `studentId` and `teacherId` are filters on [List assignment answer sheets](/api-reference/endpoint/assignments/v1-assignment-student-answer-get).
</Accordion>

## Step 5: Submit the student's answer

Send the answer, then send `isFinal` in a second call to start grading. Use documents for written work and a link for a spoken answer. One answer sheet takes one kind of answer, so never mix them.

<Tabs>
  <Tab title="Documents">
    <CodeGroup>
      ```python Python theme={null}
      with open("page-1.jpg", "rb") as one, open("page-2.jpg", "rb") as two:
          requests.patch(
              BASE_URL + sheet_path,
              headers=HEADERS,
              files=[
                  ("documents", ("page-1.jpg", one, "image/jpeg")),
                  ("documents", ("page-2.jpg", two, "image/jpeg")),
              ],
          ).raise_for_status()

      requests.patch(
          BASE_URL + sheet_path, headers=HEADERS, data={"isFinal": "true"}
      ).raise_for_status()
      ```

      ```javascript JavaScript theme={null}
      const pages = new FormData();
      pages.append("documents", pageOneFile, "page-1.jpg");
      pages.append("documents", pageTwoFile, "page-2.jpg");
      await fetch(BASE_URL + sheetPath, {
        method: "PATCH",
        headers: HEADERS,
        body: pages,
      });

      const submitPages = new FormData();
      submitPages.append("isFinal", "true");
      await fetch(BASE_URL + sheetPath, {
        method: "PATCH",
        headers: HEADERS,
        body: submitPages,
      });
      ```
    </CodeGroup>

    A PDF works the same way: one PDF per answer sheet, up to 50 pages, and never with photos.
  </Tab>

  <Tab title="Audio link">
    <CodeGroup>
      ```python Python theme={null}
      requests.patch(
          BASE_URL + sheet_path,
          headers=HEADERS,
          data={"link": "https://cdn.example.com/recordings/student-10432.mp3"},
      ).raise_for_status()

      requests.patch(
          BASE_URL + sheet_path, headers=HEADERS, data={"isFinal": "true"}
      ).raise_for_status()
      ```

      ```javascript JavaScript theme={null}
      const audio = new FormData();
      audio.append("link", "https://cdn.example.com/recordings/student-10432.mp3");
      await fetch(BASE_URL + sheetPath, {
        method: "PATCH",
        headers: HEADERS,
        body: audio,
      });

      const submitAudio = new FormData();
      submitAudio.append("isFinal", "true");
      await fetch(BASE_URL + sheetPath, {
        method: "PATCH",
        headers: HEADERS,
        body: submitAudio,
      });
      ```
    </CodeGroup>

    Host the recording yourself. The link must be an HTTPS URL that ends in `.mp3`, with no query string after it, and it must open without a login: CrazyGoldFish fetches it while your call runs. The assignment's model answer sheet must have a rubric, and only the first link on a sheet is kept.
  </Tab>
</Tabs>

<Warning>
  Send `documents` or `link` first, then `isFinal` on its own. An answer sent together with `isFinal` returns `400`. Send `isFinal` as the text `true`, not as a JSON boolean.
</Warning>

## Step 6: Get the score

Wait for the answer sheet's `status` to reach `2`, then read the marks and feedback.

<CodeGroup>
  ```python Python theme={null}
  wait_for(
      assignment_path + f"answer-sheet/?studentId={student_id}",
      pick=lambda body: next(
          sheet["status"] for sheet in body["data"] if sheet["id"] == sheet_id
      ),
  )

  score = requests.get(BASE_URL + sheet_path + "score/", headers=HEADERS).json()
  ```

  ```javascript JavaScript theme={null}
  await waitFor(
    `${assignmentPath}answer-sheet/?studentId=${studentId}`,
    (body) => body.data.find((sheet) => sheet.id === sheetId).status,
  );

  const score = await (
    await fetch(BASE_URL + sheetPath + "score/", { headers: HEADERS })
  ).json();
  ```
</CodeGroup>

<Accordion title="What you get back">
  For written answers, the marks are on `answer.marks` and the feedback is in `answer.feedbacks`:

  ```json theme={null}
  {
    "id": "3c9e1f2a-7b4d-4a8e-9f1c-2d5e6a7b8c9d",
    "assignmentName": "Unit 3 English essay",
    "marks": 2.0,
    "parentQuestions": [
      {
        "questionNumber": "1",
        "questions": [
          {
            "questionNumber": "a",
            "text": "Why does the narrator return to the village?",
            "marks": 1.0,
            "modelAnswer": {
              "stepMarking": [
                {
                  "marksplit": 1,
                  "step_wise_answer": "He returns to look after his grandmother."
                }
              ],
              "rubric": null
            },
            "answer": {
              "id": "741cd09f-543f-460e-9b6e-826cf2809cb1",
              "text": "He goes back because his grandmother is ill and alone.",
              "marks": 1.0,
              "feedbacks": [
                {
                  "feedback": "You identified the reason and used your own words.",
                  "justification": "The answer matches the model answer's step.",
                  "marks_deducted": "0",
                  "room_for_improvement": "Quote a line from the passage."
                }
              ],
              "holistic_evaluation": []
            }
          }
        ]
      }
    ]
  }
  ```

  For an audio answer, `answer.text` is the transcript, `answer.marks` stays `0`, and the score is in `answer.holistic_evaluation`:

  ```json theme={null}
  {
    "text": "The place I love most is my grandmother's village ...",
    "marks": 0.0,
    "feedbacks": [],
    "holistic_evaluation": {
      "evaluation": [
        {
          "category": "Confidence and fluency in speaking",
          "score": 4,
          "max_score": 5,
          "feedback": {
            "summary": "Clear and steady delivery with few pauses.",
            "strength": "Held a steady pace for the full two minutes.",
            "improvement": "Vary your tone to hold the listener's attention."
          },
          "confidence_flags": []
        }
      ],
      "marks": 4,
      "max_marks": 5,
      "overall_feedback": "A confident answer. Add one or two examples.",
      "wpm": 104.5
    }
  }
  ```

  Both examples are trimmed. The response has no obtained total, so add up `answer.marks`, or read `holistic_evaluation.marks`, yourself.
</Accordion>

## Good to know

<AccordionGroup>
  <Accordion title="Limits" icon="gauge">
    | Limit                        | Value                                                                                                                                                                 |
    | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | File types                   | PDF, JPEG, PNG                                                                                                                                                        |
    | File size                    | 20 MB per file                                                                                                                                                        |
    | PDF length                   | 50 pages, and one PDF per assignment or answer sheet                                                                                                                  |
    | Mixing                       | Send either one PDF or a set of images, never both. Some mixed uploads are rejected and others are accepted without an error, so keep the two apart in your own code. |
    | Audio                        | One public HTTPS `.mp3` link per answer sheet, within a maximum length set for your account                                                                           |
    | Question extraction          | A daily limit per API user. Over it, `PATCH` on the assignment returns `400`.                                                                                         |
    | Answer sheets from documents | A daily limit per API user. Over it, `isFinal` returns `429`.                                                                                                         |
    | Answer sheets from audio     | A limit per account inside a fixed time window. Over it, `isFinal` returns `429`.                                                                                     |
    | Repeats                      | One model answer sheet per assignment, and one evaluation per answer sheet                                                                                            |

    Daily limits reset shortly after midnight, server time, and every request counts, including rejected ones.
  </Accordion>

  <Accordion title="Use webhooks instead of polling" icon="bell">
    Register a webhook for each step, then fetch the result when the event arrives. See [Async jobs and webhooks](/api-reference/async-jobs-and-webhooks#register-a-webhook).

    | Step                    | Event                                                   |
    | ----------------------- | ------------------------------------------------------- |
    | 2. Question paper       | `assignment_evaluation_question_paper_extraction`       |
    | 3. Answer key or rubric | `assignment_evaluation_model_answer_extraction`         |
    | 5. Student's answer     | `assignment_evaluation_student_answer_sheet_evaluation` |

    Assignment payloads name the event in `webhook_name`, not `name`, and carry `assignment_id` plus `model_answer_sheet_id` or `ans_sheet_id`. Check `status` for `Completed` or `Failed`.
  </Accordion>

  <Accordion title="Troubleshooting" icon="wrench">
    | Error                                                         | Fix                                                                                                                                                                              |
    | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `403` `Authentication credentials were not provided.`         | Send the `Authorization: Bearer` header. This API answers `403`, not `401`.                                                                                                      |
    | An empty or redirected `PATCH`                                | Add the trailing slash to the path                                                                                                                                               |
    | The call fails after sending `isFinal`                        | Send `isFinal` as the text `true`, not a JSON boolean                                                                                                                            |
    | `Only one of documents or isFinal can be processed at a time` | Upload first, then send `isFinal` in a separate call                                                                                                                             |
    | `Assignment is not extracted yet`                             | Wait until the assignment's `status` is `2`                                                                                                                                      |
    | `Model answer sheet is not extracted yet`                     | Wait until the model answer sheet's `status` is `2`                                                                                                                              |
    | `Cannot mix PDF files with image files`                       | Put the PDF on its own assignment or answer sheet                                                                                                                                |
    | `Blurred image, Please try to capture the image again`        | Photograph the page again in better light. The same message comes back when the readability check cannot be completed, so if a clear file keeps failing, retry in a few minutes. |
    | `Only .mp3 files are allowed.`                                | Use a plain `.mp3` URL with no query string after it                                                                                                                             |
    | `URL is not publicly accessible.`                             | Host the MP3 where it opens without a login                                                                                                                                      |
    | `Rubric not found for audio evaluation`                       | Attach a `rubricId` to the model answer sheet                                                                                                                                    |
    | `429 Rate limit exceeded`                                     | Wait for the limit to reset, then send `isFinal` again                                                                                                                           |
    | `status` is `3` (Failed)                                      | Upload a clearer file and start that step again on a new assignment or answer sheet                                                                                              |
    | Still `1` (In Progress) after 10 minutes                      | Start again and [contact us](https://www.crazygoldfish.com/#comp-lxumousa) with the assignment ID                                                                                |

    Messages arrive as `error.message.non_field_errors`, or keyed by field name when a field is invalid. See [Errors](/api-reference/errors) for the full format and a helper that reads any message.
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card title="Assignment API reference" icon="code" href="/api-reference/endpoint/assignments/v1-assignment">
    Every field, response, and error.
  </Card>

  <Card title="What teachers use it for" icon="chalkboard-user" href="/products/assignment/usecase">
    The classroom problem this solves.
  </Card>
</CardGroup>
