openapi: 3.1.0
info:
  title: ExamGPT API (Private Beta)
  version: "1.0.0-beta"
  summary: Async marking API for printed/handwritten exam scripts. Private beta - early access only.
  description: |
    > **PRIVATE BETA - NOT PUBLICLY AVAILABLE**
    >
    > The ExamGPT API is in closed beta with a small number of enterprise
    > design partners. API keys are not self-serve. To request early
    > access, contact <enterprise@examgpt.net> or use the contact form at
    > <https://excelas.ai/contact?topic=examgpt-api-early-access>.
    >
    > Endpoints, hostnames and example keys in this reference are
    > illustrative; the production base URL is provisioned per-tenant
    > during onboarding.

    The ExamGPT API marks scanned student scripts against a Question Paper
    and a Marking Rubric and returns fully codified marking data:

    - per-question marks with mark-key-level breakdown (`M1`, `A1`, `B1`, `AO1.1`, ...),
    - examiner-style feedback per question,
    - OCR text per answer region,
    - per-page bounding boxes for printed-question and student-answer regions,
    - per-student totals, percentages and (optionally) predicted grades.

    All marking is reported as model `examgpt-2.5-pro`.

    ## Two ways in

    **Simple (recommended)** - one `multipart/form-data` POST with the three
    files inline:

    ```
    POST /v1/marking-jobs
      question_paper:  qp.pdf
      marking_rubric:  ms.pdf
      scripts:         class.zip   (or one student PDF)
      metadata:        { subject, exam_board, ... }
    ```

    **Advanced** - reuse a registered Assessment across many cohorts, or
    upload very large ZIPs resumably with TUS, then submit by reference:

    ```
    POST /v1/marking-jobs
      { assessment_id: "asm_...", upload_id: "upl_..." }
    ```

    **Base URL:** `https://public-api.examgpt.net/v1`

    All endpoints require a bearer API key:

    ```
    Authorization: Bearer egpt_live_sk_...
    ```
  contact:
    name: ExamGPT Enterprise Support
    email: enterprise@examgpt.net
    url: https://examgpt.net/enterprise
  termsOfService: https://examgpt.net/legal/api-terms
  license:
    name: Proprietary - ExamGPT Enterprise Terms
    url: https://examgpt.net/legal/api-terms

servers:
  - url: https://public-api.examgpt.net/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Marking Jobs
    description: |
      Async marking jobs - the headline endpoint. Submit a Question Paper,
      a Marking Rubric and one or more student scripts (single PDF or ZIP)
      in a single `multipart/form-data` POST. The codified mark scheme is
      auto-extracted from the rubric PDF; you can override it with a
      structured `questions` JSON.
  - name: Cover Sheets
    description: |
      Generate printable PDF cover sheets with embedded QR codes from a
      class roster. The QR code carries the student's identifier (and
      optionally name / class / external IDs) so we can map every script
      back to a student automatically when the ZIP is submitted.
  - name: Webhooks
    description: |
      Push notifications for marking-job lifecycle events. HMAC-signed.
  - name: Advanced - Assessments
    description: |
      Optional. Pre-register a reusable Assessment (Question Paper +
      Marking Rubric + codified questions) so multiple Marking Jobs can
      run against it without re-uploading the same papers.
  - name: Advanced - Uploads
    description: |
      Optional. Resumable TUS 1.0.0 uploads for ZIPs over the multipart
      size limit (100 MB). Returns an `upload_id` you can pass to
      `POST /v1/marking-jobs` instead of an inline file.

paths:
  /marking-jobs:
    post:
      tags: [Marking Jobs]
      summary: Submit a marking job
      operationId: createMarkingJob
      description: |
        Create a marking job. Accepts two body types selected by
        `Content-Type`:

        - **`multipart/form-data`** (simple): inline `question_paper`,
          `marking_rubric` and `scripts` files plus a `metadata` JSON
          field. The codified mark scheme is auto-extracted from the
          rubric PDF; supply a `questions` JSON to override.
        - **`application/json`** (advanced): pass `assessment_id` and
          `upload_id` to reuse a pre-registered Assessment and a
          previously TUS-uploaded ZIP.

        Responds immediately with `202 Accepted`. Track progress by
        polling `GET /marking-jobs/{id}` or by subscribing to webhooks
        via `callback_url`.

        Submit `Idempotency-Key` to make creation safe to retry.

        ### How student scripts are mapped back to students

        `scripts` auto-detects format from the file content - no `mode`
        flag needed. Three layouts are supported:

        1. **ZIP of one-PDF-per-student.** Each PDF is one student.
        2. **Single combined PDF.** A multi-student PDF (e.g. a single
           scan of the whole class) is auto-split into per-student
           chunks at every detected cover-sheet QR code.
        3. **Single student PDF.** One PDF, one student.

        Student identification within each chunk is resolved in this
        order:

        1. **Cover-sheet QR.** Generate cover sheets via
           `POST /v1/cover-sheets` and have students staple them to
           their answer scripts. The QR carries `student_identifier`,
           `student_name` and any `external_ids` from the roster, so
           every `StudentResult` comes back fully identified.
        2. **Filename fallback.** If no QR is detected, the PDF
           filename stem is used as `student_identifier`
           (e.g. `00123456.pdf` -> `student_identifier: "00123456"`,
           `student_name: null`). Useful when schools already have
           per-student PDFs named by candidate number.

        If both fail the chunk lands in `failed_count` with a `warnings`
        entry.

        ### File size limits

        Multipart uploads are capped at 100 MB total body for the
        `scripts` field. For larger ZIPs (up to 500 MB), upload the ZIP
        first via `POST /v1/uploads` (TUS) and pass the returned
        `upload_id` in the JSON body. The API responds `413
        payload_too_large` with a `Location` header pointing at
        `/v1/uploads` if you exceed the multipart limit.
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, maxLength: 128 }
          description: |
            Echoed back on duplicate creates within 24h. Re-uses the
            original `id` if the same key is replayed with an identical
            body.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [question_paper, scripts, metadata]
              properties:
                question_paper:
                  type: string
                  format: binary
                  description: PDF of the printed Question Paper.
                marking_rubric:
                  type: string
                  format: binary
                  description: |
                    PDF of the official Marking Rubric. Required unless
                    `questions` is supplied. The codified mark scheme is
                    auto-extracted from this PDF.
                questions:
                  type: string
                  format: binary
                  description: |
                    Optional JSON file with structured questions
                    (`Question[]`). Overrides what is auto-extracted from
                    the marking rubric PDF. Supply this when you want
                    guaranteed mark-key fidelity.
                scripts:
                  type: string
                  format: binary
                  description: |
                    The student answer scripts. Format auto-detected:

                    - **ZIP** containing one PDF per student, OR
                    - **Single PDF** for one student, OR
                    - **Single combined PDF** of many students (will be
                      auto-split at every cover-sheet QR code).

                    Students are identified from cover-sheet QR codes
                    (see `POST /v1/cover-sheets`); if no QR is detected
                    in a chunk, the PDF filename stem is used as the
                    `student_identifier`.

                    Max 100 MB; for larger payloads use TUS uploads
                    (see the "Advanced - Uploads" tag).
                metadata:
                  type: string
                  description: |
                    JSON-encoded `AssessmentMetadata`. Required.
                  example: |
                    {"subject":"maths","exam_board":"AQA","course_level":"GCSE","paper_number":1,"paper_tier":"H","year":2024,"grade_boundaries":{"9":80,"8":71,"7":62,"6":53,"5":44,"4":35,"3":26}}
                processing_config:
                  type: string
                  description: |
                    Optional JSON-encoded `ProcessingConfig`.
                callback_url:
                  type: string
                  format: uri
                  description: HTTPS endpoint for webhook events.
                metadata_tags:
                  type: string
                  description: |
                    Optional JSON object of free-form tags echoed back on
                    every webhook (school, cohort, ...).
            encoding:
              metadata:          { contentType: application/json }
              processing_config: { contentType: application/json }
              metadata_tags:     { contentType: application/json }
              questions:         { contentType: application/json }
          application/json:
            schema:
              $ref: '#/components/schemas/MarkingJobCreateByRef'
            examples:
              advanced:
                summary: Advanced - reuse a pre-registered Assessment
                value:
                  assessment_id: asm_01HZX9R2QK5Z7JX3F1H8GTA4PV
                  upload_id: upl_01HZX9V0M2W3JKQ8Z2P0SH7TYR
                  processing_config:
                    page_offset: 1
                    return_bounding_boxes: true
                    return_cropped_image_urls: false
                  callback_url: https://hooks.acme-school.example.com/examgpt
                  metadata: { school: "Acme High", cohort: "Y11-2024" }
      responses:
        '202':
          description: Marking job accepted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarkingJob' }
              examples:
                queued:
                  value:
                    id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    assessment_id: asm_01HZX9R2QK5Z7JX3F1H8GTA4PV
                    status: queued
                    progress: 0
                    model: examgpt-2.5-pro
                    student_count: null
                    completed_count: 0
                    failed_count: 0
                    created_at: "2026-05-08T12:24:11Z"
                    completed_at: null
                    poll_url: https://public-api.examgpt.net/v1/marking-jobs/mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    result_url: null
                    webhook_subscribed: true
                    metadata: { school: "Acme High", cohort: "Y11-2024" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413':
          description: Multipart body too large; switch to TUS upload.
          headers:
            Location:
              schema: { type: string, format: uri }
              description: TUS endpoint to use instead.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                tooBig:
                  value:
                    success: false
                    error:
                      code: payload_too_large
                      message: "scripts ZIP exceeds 100 MB. Upload via TUS at /v1/uploads, then resubmit with upload_id."
                      details: { multipart_limit_bytes: 104857600, tus_endpoint: "/v1/uploads" }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /marking-jobs/{id}:
    get:
      tags: [Marking Jobs]
      summary: Get marking-job status and progress
      operationId: getMarkingJob
      description: |
        Lightweight status endpoint for polling. Safe to call once every
        few seconds. For push-based updates set `callback_url` and
        consume the webhook events instead.

        Drives a UI progress bar from `progress` (0-100) and the
        per-student counters.
      parameters:
        - $ref: '#/components/parameters/MarkingJobId'
      responses:
        '200':
          description: Current state of the marking job.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarkingJob' }
              examples:
                processing:
                  value:
                    id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    assessment_id: asm_01HZX9R2QK5Z7JX3F1H8GTA4PV
                    status: processing
                    progress: 47.5
                    model: examgpt-2.5-pro
                    student_count: 32
                    completed_count: 15
                    failed_count: 0
                    created_at: "2026-05-08T12:24:11Z"
                    completed_at: null
                    poll_url: https://public-api.examgpt.net/v1/marking-jobs/mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    result_url: null
                    webhook_subscribed: true
                    metadata: { school: "Acme High", cohort: "Y11-2024" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/MarkingJobNotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /marking-jobs/{id}/results:
    get:
      tags: [Marking Jobs]
      summary: Get full marking results
      operationId: getMarkingJobResults
      description: |
        Returns the full `MarkingResult` payload once the job reaches
        `status: completed`. For ZIP submissions this includes one
        `StudentResult` per detected student.

        Calling this endpoint before completion returns
        `409 marking_job_pending`.
      parameters:
        - $ref: '#/components/parameters/MarkingJobId'
      responses:
        '200':
          description: Full marking results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarkingResult' }
              examples:
                zipResult:
                  $ref: '#/components/examples/MarkingResultZip'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/MarkingJobNotFound' }
        '409':
          description: Marking job is not yet completed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                pending:
                  value: { success: false, error: { code: marking_job_pending, message: "Marking job is still processing." } }
        '500': { $ref: '#/components/responses/InternalError' }

  /marking-jobs/{id}/students/{student_id}:
    get:
      tags: [Marking Jobs]
      summary: Get a single student's marking
      operationId: getMarkingJobStudent
      description: |
        Returns just one `StudentResult` from a completed marking job.
        Useful for review UIs that want to drill into a single student
        without downloading the full batch payload.
      parameters:
        - $ref: '#/components/parameters/MarkingJobId'
        - in: path
          name: student_id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Per-student marking detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StudentResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Marking job or student not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }

  /cover-sheets:
    post:
      tags: [Cover Sheets]
      summary: Generate printable cover sheets with QR codes
      operationId: createCoverSheetPack
      description: |
        Generate a pack of printable A4 PDF cover sheets - one per
        student in your roster - each carrying:

        - the student's name in large type at the top,
        - the assessment title and any class / Wonde IDs you supplied,
        - a QR code that encodes the student's `identifier`, `name` and
          any `external_ids` so we can map every script back to the
          student automatically when the ZIP is submitted.

        Print the pack, distribute one cover sheet per candidate, ask
        them to staple it (or place it as the first page) on top of
        their answer script. Then submit the whole class's scripts to
        `POST /v1/marking-jobs` - we extract the QR on each cover sheet
        and resolve `student_id`, `student_name` and `external_ids`
        without you sending us the roster again.

        ### QR payload format

        The QR code on each cover sheet encodes a compact JSON object:

        ```
        {"v":1,"i":"<identifier>","n":"<name>","c":"<class_id>","w":"<wonde_id>","a":"<assessment_id>"}
        ```

        Field keys are abbreviated to keep the QR small. `v` is the
        format version (currently `1`). `i` is the only required field;
        the others are echoed back into `StudentResult.student_name` and
        `StudentResult.external_ids` so your downstream system can join
        on whichever ID it knows. Schools that already produce their own
        QR codes can encode this exact payload to skip
        `POST /v1/cover-sheets` entirely.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CoverSheetPackCreate' }
            examples:
              year11Maths:
                summary: A class of three students for an AQA GCSE Maths paper
                value:
                  assessment_id: asm_01HZX9R2QK5Z7JX3F1H8GTA4PV
                  title: "AQA GCSE Maths Paper 1H - Y11 mock"
                  class_label: "Y11 Set 1"
                  layout: a4_portrait_full_page
                  roster:
                    - { identifier: "00123456", name: "Jane Smith",   class_id: "Y11A", wonde_id: "wonde_stu_abc123" }
                    - { identifier: "00123457", name: "John Doe",     class_id: "Y11A", wonde_id: "wonde_stu_abc124" }
                    - { identifier: "00123458", name: "Aisha Khan",   class_id: "Y11A", wonde_id: "wonde_stu_abc125" }
      responses:
        '201':
          description: Cover-sheet pack ready.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CoverSheetPack' }
              examples:
                ready:
                  value:
                    id: csp_01HZXC2K4NPY9R5T0V8X1A3B6F
                    assessment_id: asm_01HZX9R2QK5Z7JX3F1H8GTA4PV
                    student_count: 3
                    layout: a4_portrait_full_page
                    download_url: "https://public-api.examgpt.net/files/csp_01HZXC2K4NPY9R5T0V8X1A3B6F.pdf?sig=...&exp=1715180000"
                    download_format: pdf
                    expires_at: "2026-05-09T16:21:04Z"
                    created_at: "2026-05-08T16:21:04Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /assessments:
    post:
      tags: [Advanced - Assessments]
      summary: Create a reusable assessment
      operationId: createAssessment
      description: |
        Pre-register an Assessment so it can be referenced by many
        Marking Jobs without re-uploading the Question Paper and
        Marking Rubric.

        Most integrators do not need this - just use `POST /marking-jobs`
        directly with files inline. Use this when you mark the same
        paper across many cohorts, or when you want to keep a stable
        `assessment_id` in your audit trail.

        Attach the PDFs in a follow-up call to
        `POST /assessments/{id}/papers`. The codified questions are
        auto-extracted from the rubric PDF; supply your own with
        `POST /assessments/{id}/questions` for guaranteed mark-key
        fidelity.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssessmentCreate'
            examples:
              gcseMaths:
                summary: AQA GCSE Maths Paper 1H - June 2024
                value:
                  title: AQA GCSE Maths Paper 1H - June 2024
                  metadata:
                    subject: maths
                    exam_board: AQA
                    course_level: GCSE
                    difficulty_tier: higher
                    qualification_title: GCSE (9-1) Mathematics
                    spec_code: "8300"
                    paper_number: 1
                    paper_tier: H
                    paper_variant: Non-Calculator
                    season: Summer
                    year: 2024
                    language: en-GB
                    is_ofqual_regulated: true
                    grade_boundaries: { "9": 80, "8": 71, "7": 62, "6": 53, "5": 44, "4": 35, "3": 26 }
      responses:
        '201':
          description: Assessment created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Assessment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /assessments/{id}/papers:
    post:
      tags: [Advanced - Assessments]
      summary: Attach Question Paper and/or Marking Rubric PDFs
      operationId: uploadAssessmentPapers
      description: |
        Upload the Question Paper and the Marking Rubric for an existing
        Assessment as `multipart/form-data`. Both files are optional in
        a single request - you may attach one now and the other later.
      parameters:
        - $ref: '#/components/parameters/AssessmentId'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                question_paper:
                  type: string
                  format: binary
                marking_rubric:
                  type: string
                  format: binary
              minProperties: 1
      responses:
        '200':
          description: Files attached.
          content:
            application/json:
              schema:
                type: object
                properties:
                  question_paper: { $ref: '#/components/schemas/AttachedFile' }
                  marking_rubric: { $ref: '#/components/schemas/AttachedFile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /assessments/{id}/questions:
    post:
      tags: [Advanced - Assessments]
      summary: Override the auto-extracted codified questions
      operationId: createAssessmentQuestions
      description: |
        Replace the questions auto-extracted from the rubric PDF with a
        structured array. Use this when you have your own canonical
        mark-key list and want guaranteed fidelity.

        Once at least one Question is registered the Assessment moves
        from `draft` to `ready` and can be referenced by a Marking Job.
      parameters:
        - $ref: '#/components/parameters/AssessmentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [questions]
              properties:
                questions:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: '#/components/schemas/QuestionCreate' }
      responses:
        '201':
          description: Questions inserted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  inserted: { type: integer }
                  questions:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        question_number: { type: string }
                        max_marks: { type: integer }
                  assessment_status:
                    type: string
                    enum: [draft, ready, archived]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/AssessmentNotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /uploads:
    post:
      tags: [Advanced - Uploads]
      summary: Create a resumable upload (TUS 1.0.0)
      operationId: createUpload
      description: |
        Header-based **TUS 1.0.0 creation** request for resumable
        uploads of ZIPs over the 100 MB multipart limit.

        The response includes `Location: /v1/uploads/{upload_id}` which
        the client uses for `PATCH` chunk uploads:

        ```
        PATCH /v1/uploads/{upload_id}
        Tus-Resumable:    1.0.0
        Upload-Offset:    <bytes already uploaded>
        Content-Type:     application/offset+octet-stream
        ```

        Recommended chunk size is 6 MB. Any TUS 1.0.0 client works -
        `tus-js-client`, `tusc`, `tusd` and the official iOS/Android
        libraries are all compatible.

        Once the upload is complete, pass the returned `upload_id` to
        `POST /v1/marking-jobs` with a JSON body:

        ```
        { "assessment_id": "asm_...", "upload_id": "upl_..." }
        ```

        Uploads expire 24 hours after creation if not consumed.
      parameters:
        - in: header
          name: Tus-Resumable
          required: true
          schema: { type: string, enum: ["1.0.0"] }
        - in: header
          name: Upload-Length
          required: true
          schema: { type: integer, minimum: 1 }
        - in: header
          name: Upload-Metadata
          required: false
          schema: { type: string }
      responses:
        '201':
          description: Upload session created.
          headers:
            Location:
              schema: { type: string, format: uri }
            Tus-Resumable:
              schema: { type: string, enum: ["1.0.0"] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /uploads/{upload_id}:
    get:
      tags: [Advanced - Uploads]
      summary: Get upload metadata
      operationId: getUpload
      parameters:
        - $ref: '#/components/parameters/UploadId'
      responses:
        '200':
          description: Upload metadata.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Upload' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/UploadNotFound' }
        '409': { $ref: '#/components/responses/UploadExpired' }
        '500': { $ref: '#/components/responses/InternalError' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ExamGPT API key (egpt_live_sk_... or egpt_test_sk_...)

  parameters:
    AssessmentId:
      in: path
      name: id
      required: true
      schema: { type: string, pattern: '^asm_[A-Z0-9]+$' }
      description: Assessment identifier returned by `POST /assessments`.
    MarkingJobId:
      in: path
      name: id
      required: true
      schema: { type: string, pattern: '^mjb_[A-Z0-9]+$' }
      description: Marking-job identifier returned by `POST /marking-jobs`.
    UploadId:
      in: path
      name: upload_id
      required: true
      schema: { type: string, pattern: '^upl_[A-Z0-9]+$' }
      description: Upload identifier returned by `POST /uploads`.

  responses:
    Unauthorized:
      description: Missing or invalid bearer API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              value: { success: false, error: { code: unauthorized, message: "Missing Authorization header." } }
            invalid:
              value: { success: false, error: { code: invalid_api_key, message: "API key is revoked or unknown." } }
    AssessmentNotFound:
      description: Assessment not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    MarkingJobNotFound:
      description: Marking job not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            notFound:
              value: { success: false, error: { code: marking_job_not_found, message: "Marking job 'mjb_...' was not found." } }
    UploadNotFound:
      description: Upload not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UploadExpired:
      description: Upload TTL exceeded.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            expired:
              value: { success: false, error: { code: upload_expired, message: "Upload TTL exceeded (24h)." } }
    UnprocessableEntity:
      description: Request body failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unsupportedSubject:
              value: { success: false, error: { code: unsupported_subject, message: "metadata.subject 'foo' is not in the supported enum." } }
            qpMsMismatch:
              value:
                success: false
                error:
                  code: qp_ms_question_count_mismatch
                  message: "Question paper has 28 questions but mark scheme has 27."
                  details: { qp_count: 28, ms_count: 27 }
            missingScripts:
              value:
                success: false
                error:
                  code: missing_required_field
                  message: "scripts is required (single PDF or ZIP)."
    PayloadTooLarge:
      description: File exceeds size limit.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            rateLimited:
              value: { success: false, error: { code: rate_limited, message: "Too many requests. Retry after the Retry-After header." } }
    InternalError:
      description: Unexpected server-side error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:

    Subject:
      type: string
      enum:
        - maths
        - further_maths
        - physics
        - chemistry
        - biology
        - english_language
        - english_literature
        - history
        - geography
        - economics
        - business
        - computer_science
        - psychology
        - sociology
        - religious_studies
        - modern_languages
        - art
        - music
        - other

    ExamBoard:
      type: string
      enum: [AQA, OCR, Edexcel, WJEC, CCEA, CIE, IB, AP, SQA, other]

    CourseLevel:
      type: string
      enum: [KS3, GCSE, IGCSE, A_LEVEL, AS_LEVEL, IB_DP, IB_MYP, AP, BTEC, other]

    DifficultyTier:
      type: string
      nullable: true
      enum: [foundation, higher, standard, ordinary, extended]

    Model:
      type: string
      enum: [examgpt-2.5-pro]
      default: examgpt-2.5-pro
      description: |
        The marker model. The response shape is stable as the marker
        improves underneath.

    AssessmentMetadata:
      type: object
      required: [subject, exam_board, course_level]
      properties:
        subject:             { $ref: '#/components/schemas/Subject' }
        exam_board:          { $ref: '#/components/schemas/ExamBoard' }
        course_level:        { $ref: '#/components/schemas/CourseLevel' }
        difficulty_tier:     { $ref: '#/components/schemas/DifficultyTier' }
        qualification_title: { type: string, example: "GCSE (9-1) Mathematics" }
        spec_code:           { type: string, example: "8300" }
        paper_number:        { type: integer, minimum: 1, example: 1 }
        paper_tier:
          type: string
          nullable: true
          enum: [F, H, S]
        paper_variant:       { type: string, example: "Non-Calculator" }
        season:              { type: string, example: "Summer" }
        year:                { type: integer, minimum: 1900, maximum: 2100, example: 2024 }
        language:            { type: string, default: en-GB }
        is_ofqual_regulated: { type: boolean }
        grade_boundaries:
          type: object
          additionalProperties: { type: integer, minimum: 0 }
          description: |
            Grade-to-marks lookup. When provided, drives `predicted_grade`
            and `summary.grade_distribution` in results.
          example: { "9": 80, "8": 71, "7": 62, "6": 53, "5": 44, "4": 35, "3": 26 }

    AttachedFile:
      type: object
      required: [file_id, page_count]
      properties:
        file_id:    { type: string }
        page_count: { type: integer, minimum: 1 }

    AssessmentCreate:
      type: object
      required: [title, metadata]
      properties:
        title:    { type: string, minLength: 1, maxLength: 200 }
        metadata: { $ref: '#/components/schemas/AssessmentMetadata' }

    Assessment:
      type: object
      required: [id, title, metadata, questions_count, status, created_at]
      properties:
        id:              { type: string, example: "asm_01HZX9R2QK5Z7JX3F1H8GTA4PV" }
        title:           { type: string }
        metadata:        { $ref: '#/components/schemas/AssessmentMetadata' }
        question_paper:
          oneOf:
            - $ref: '#/components/schemas/AttachedFile'
            - type: 'null'
        marking_rubric:
          oneOf:
            - $ref: '#/components/schemas/AttachedFile'
            - type: 'null'
        questions_count: { type: integer, minimum: 0 }
        status:          { type: string, enum: [draft, ready, archived] }
        created_at:      { type: string, format: date-time }

    MarkSchemeDetail:
      type: object
      required: [mark_scheme_mark, mark_scheme_details]
      properties:
        mark_scheme_mark:
          type: string
          description: Mark key, e.g. `M1`, `A1`, `B1`, `AO3.1`.
          example: "M1"
        mark_scheme_details:
          type: string
          description: Short canonical answer / acceptance criteria.
          example: "25/4 oe"
        additional_guidance:
          type: string
          nullable: true
          example: "for correct first step"

    QuestionCreate:
      type: object
      required: [question_number, question_text, max_marks, mark_scheme_details]
      properties:
        question_number:
          type: string
          example: "1(b)(ii)"
        question_text:
          type: string
        max_marks:
          type: integer
          minimum: 0
        page_numbers:
          type: array
          items: { type: integer, minimum: 1 }
        mark_scheme_answer:
          type: string
          example: "M1: 25/4 oe, A1: correct answer"
        mark_scheme_details:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/MarkSchemeDetail' }
        ocr_required:
          type: boolean
          default: true

    Question:
      allOf:
        - $ref: '#/components/schemas/QuestionCreate'
        - type: object
          required: [id]
          properties:
            id: { type: string, example: "q_01HZX9...B" }

    MarkingCriterion:
      type: object
      required: [mark, awarded, reason]
      properties:
        mark:
          type: string
          example: "AO1.1"
        awarded:
          type: boolean
        reason:
          type: string
          example: "The student correctly stated that the nucleus..."

    ProcessingConfig:
      type: object
      description: |
        Per-job knobs.
      properties:
        model:
          $ref: '#/components/schemas/Model'
        page_offset:
          type: integer
          minimum: 0
          default: 0
          description: Number of cover-sheet pages to skip in the input PDF.
        return_cropped_image_urls:
          type: boolean
          default: false
          description: |
            When `true`, every `PerQuestionResult.cropped_image_url` is a
            short-lived signed URL to a per-question crop. Adds latency.
        return_bounding_boxes:
          type: boolean
          default: true
          description: |
            When `true`, `StudentResult.pages[]` is populated with per-page
            bounding boxes labelled `printed_question` or `student_answer`.

    Upload:
      type: object
      required: [upload_id, status, expires_at]
      properties:
        upload_id:    { type: string, example: "upl_01HZX9V0M2W3JKQ8Z2P0SH7TYR" }
        filename:     { type: string, example: "year11-scripts.zip" }
        content_type: { type: string, example: "application/zip" }
        size_bytes:   { type: integer, minimum: 1 }
        status:       { type: string, enum: [in_progress, complete, expired] }
        expires_at:   { type: string, format: date-time }
        chunk_size:
          type: integer
          default: 6291456
          description: Recommended chunk size in bytes (6 MB).

    RosterEntry:
      type: object
      required: [identifier]
      description: |
        One row of a class roster passed to `POST /v1/cover-sheets`.
        Every field except `identifier` is optional and is echoed back
        unchanged into the cover-sheet QR and into
        `StudentResult.student_name` / `StudentResult.external_ids` on
        the marked output.
      properties:
        identifier:
          type: string
          description: |
            Stable candidate identifier (e.g. UCI, candidate number).
            Becomes `StudentResult.student_identifier`.
          example: "00123456"
        name:
          type: string
          description: Becomes `StudentResult.student_name`.
          example: "Jane Smith"
        class_id:
          type: string
          description: |
            School-internal class / set / form identifier. Echoed into
            `StudentResult.external_ids.class_id`.
          example: "Y11A"
        wonde_id:
          type: string
          description: |
            Wonde MIS student ID, if you sync via Wonde. Echoed into
            `StudentResult.external_ids.wonde_id`.
          example: "wonde_stu_abc123"

    CoverSheetPackCreate:
      type: object
      required: [roster]
      properties:
        assessment_id:
          type: string
          nullable: true
          description: |
            Optional. If supplied, the assessment title and metadata are
            printed on every cover sheet for visual confirmation, and
            the QR also encodes `a:<assessment_id>` so submitting a
            script against the wrong assessment is detected and warned
            on at marking time.
          example: "asm_01HZX9R2QK5Z7JX3F1H8GTA4PV"
        title:
          type: string
          description: |
            Title printed at the top of every cover sheet. If omitted
            and `assessment_id` is supplied, the Assessment's title is
            used.
          example: "AQA GCSE Maths Paper 1H - Y11 mock"
        class_label:
          type: string
          description: Optional class label printed under the title (e.g. "Y11 Set 1").
        layout:
          type: string
          enum: [a4_portrait_full_page, a4_portrait_half_page, label_4x6]
          default: a4_portrait_full_page
          description: |
            Page layout for each cover sheet. `a4_portrait_full_page`
            (default) is one cover sheet per A4 page. `label_4x6`
            prints to standard 4x6" thermal labels.
        roster:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: '#/components/schemas/RosterEntry' }

    CoverSheetPack:
      type: object
      required: [id, student_count, layout, download_url, download_format, expires_at, created_at]
      properties:
        id:
          type: string
          example: "csp_01HZXC2K4NPY9R5T0V8X1A3B6F"
        assessment_id:
          type: string
          nullable: true
        student_count:
          type: integer
          minimum: 1
        layout:
          type: string
          enum: [a4_portrait_full_page, a4_portrait_half_page, label_4x6]
        download_url:
          type: string
          format: uri
          description: |
            Short-lived signed URL to the printable PDF. The PDF
            contains one cover sheet per roster entry, in the same
            order as the input roster.
        download_format:
          type: string
          enum: [pdf]
        expires_at:
          type: string
          format: date-time
          description: When `download_url` stops resolving (typically 24h).
        created_at:
          type: string
          format: date-time

    MarkingJobCreateByRef:
      type: object
      required: [assessment_id, upload_id]
      description: |
        Advanced JSON-body shape for `POST /v1/marking-jobs`. Use when
        you have a pre-registered Assessment and have uploaded the
        scripts ZIP via TUS.
      properties:
        assessment_id:
          type: string
          example: "asm_01HZX9R2QK5Z7JX3F1H8GTA4PV"
        upload_id:
          type: string
          example: "upl_01HZX9V0M2W3JKQ8Z2P0SH7TYR"
        processing_config:
          $ref: '#/components/schemas/ProcessingConfig'
        callback_url:
          type: string
          format: uri
        idempotency_key:
          type: string
          maxLength: 128
        metadata:
          type: object
          additionalProperties: true

    MarkingJob:
      type: object
      required: [id, assessment_id, status, progress, model, created_at]
      description: |
        Status object returned by `POST /v1/marking-jobs` and `GET
        /v1/marking-jobs/{id}`. Use `status`, `progress` (0-100) and
        the per-student counters to drive UI.
      properties:
        id:                 { type: string, example: "mjb_01HZXA1B2C3D4E5F6G7H8J9K0L" }
        assessment_id:      { type: string }
        status:
          type: string
          enum: [queued, processing, completed, failed, cancelled]
        progress:           { type: number, minimum: 0, maximum: 100 }
        model:              { type: string, example: "examgpt-2.5-pro" }
        student_count:      { type: integer, nullable: true, minimum: 0 }
        completed_count:    { type: integer, minimum: 0 }
        failed_count:       { type: integer, minimum: 0 }
        created_at:         { type: string, format: date-time }
        completed_at:       { type: string, format: date-time, nullable: true }
        poll_url:
          type: string
          format: uri
          description: Convenience URL for the next `GET /v1/marking-jobs/{id}`.
        result_url:         { type: string, format: uri, nullable: true }
        webhook_subscribed: { type: boolean }
        metadata:
          type: object
          additionalProperties: true

    BoundingBox:
      type: object
      required: [x1, y1, x2, y2]
      properties:
        x1: { type: integer }
        y1: { type: integer }
        x2: { type: integer }
        y2: { type: integer }

    PageBoundingBox:
      type: object
      required: [type, bbox, confidence]
      properties:
        type:
          type: string
          enum: [printed_question, student_answer]
        bbox:       { $ref: '#/components/schemas/BoundingBox' }
        confidence: { type: number, minimum: 0, maximum: 1 }

    Page:
      type: object
      required: [page_number, page_width, page_height, bounding_boxes]
      properties:
        page_number:    { type: integer, minimum: 1 }
        page_width:     { type: integer, minimum: 1 }
        page_height:    { type: integer, minimum: 1 }
        bounding_boxes:
          type: array
          items: { $ref: '#/components/schemas/PageBoundingBox' }

    OcrResult:
      type: object
      required: [text, confidence]
      properties:
        text:       { type: string }
        confidence: { type: number, minimum: 0, maximum: 1 }

    PerQuestionResult:
      type: object
      required:
        - question_id
        - question_number
        - max_marks
        - marks_awarded
        - marking_breakdown
        - examiner_feedback
        - ocr
        - confidence
        - requires_human_review
      properties:
        question_id:           { type: string }
        question_number:       { type: string, example: "1(b)" }
        question_text:         { type: string }
        max_marks:             { type: integer, minimum: 0 }
        marks_awarded:         { type: number, minimum: 0 }
        marking_breakdown:
          type: array
          items: { $ref: '#/components/schemas/MarkingCriterion' }
        examiner_feedback:     { type: string }
        ocr:                   { $ref: '#/components/schemas/OcrResult' }
        page_numbers:
          type: array
          items: { type: integer, minimum: 1 }
        cropped_image_url:
          type: string
          format: uri
          nullable: true
          description: |
            Short-lived signed URL. Only set if
            `processing_config.return_cropped_image_urls = true`.
        confidence:            { type: number, minimum: 0, maximum: 1 }
        requires_human_review: { type: boolean }

    StudentResult:
      type: object
      required:
        - student_id
        - student_identifier
        - identification_source
        - source_pdf_url
        - total_marks_awarded
        - total_marks_possible
        - percentage
        - per_question
      properties:
        student_id:
          type: string
          example: "stu_01HZX...J"
          description: Internal ID assigned by ExamGPT for this student-on-this-job.
        student_identifier:
          type: string
          example: "00123456"
          description: |
            The candidate identifier we extracted. Comes from the
            cover-sheet QR's `i` field; falls back to the PDF filename
            stem if no QR was detected.
        student_name:
          type: string
          nullable: true
          description: |
            Populated from the cover-sheet QR's `n` field if you
            generated cover sheets via `POST /v1/cover-sheets`.
            `null` when the script was identified by filename or by a
            QR that did not include a name.
        external_ids:
          type: object
          description: |
            Pass-through of any `class_id` / `wonde_id` / extra fields
            from the cover-sheet QR. Lets you join `StudentResult` back
            to your SIS / MIS without a roster lookup.
          properties:
            class_id: { type: string, nullable: true }
            wonde_id: { type: string, nullable: true }
          additionalProperties: { type: string }
        identification_source:
          type: string
          enum: [cover_sheet_qr, filename]
          description: How `student_identifier` was resolved.
        source_pdf_url:
          type: string
          format: uri
          description: Short-lived signed URL (~1h) to the per-student split PDF.
        page_range:
          type: array
          items: { type: integer, minimum: 1 }
          minItems: 2
          maxItems: 2
        total_marks_awarded:  { type: number, minimum: 0 }
        total_marks_possible: { type: integer, minimum: 0 }
        percentage:           { type: number, minimum: 0, maximum: 100 }
        predicted_grade:
          type: string
          nullable: true
        per_question:
          type: array
          items: { $ref: '#/components/schemas/PerQuestionResult' }
        pages:
          type: array
          items: { $ref: '#/components/schemas/Page' }
        warnings:
          type: array
          items: { type: string }

    MarkingResult:
      type: object
      required: [marking_job_id, model, assessment, summary, students, usage]
      properties:
        marking_job_id: { type: string }
        model:          { type: string, example: "examgpt-2.5-pro" }
        assessment:     { $ref: '#/components/schemas/Assessment' }
        summary:
          type: object
          required: [student_count, mean_score, median_score]
          properties:
            student_count:      { type: integer, minimum: 0 }
            mean_score:         { type: number }
            median_score:       { type: number }
            grade_distribution:
              type: object
              additionalProperties: { type: integer, minimum: 0 }
        students:
          type: array
          items: { $ref: '#/components/schemas/StudentResult' }
        usage:
          type: object
          required: [ocr_pages, marked_questions, cost_credits]
          description: |
            Job-level usage totals used for billing.
          properties:
            ocr_pages:        { type: integer, minimum: 0 }
            marked_questions: { type: integer, minimum: 0 }
            cost_credits:     { type: number,  minimum: 0 }

    WebhookEvent:
      type: object
      required: [event, marking_job_id, occurred_at, data]
      description: |
        POSTed to `callback_url`. Verify the `X-ExamGPT-Signature`
        header (HMAC-SHA256 of the raw request body using your webhook
        secret).
      properties:
        event:
          type: string
          enum:
            - marking_job.queued
            - marking_job.progress
            - marking_job.student_completed
            - marking_job.completed
            - marking_job.failed
        marking_job_id: { type: string }
        occurred_at:    { type: string, format: date-time }
        data:
          type: object
          description: |
            A subset of `MarkingJob` or `StudentResult` shaped for the
            event - see the per-event examples.

    Error:
      type: object
      required: [success, error]
      properties:
        success:
          type: boolean
          enum: [false]
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - invalid_api_key
                - assessment_not_found
                - marking_job_not_found
                - marking_job_pending
                - upload_expired
                - upload_not_found
                - missing_required_field
                - unsupported_subject
                - qp_ms_question_count_mismatch
                - payload_too_large
                - rate_limited
                - internal_error
            message: { type: string }
            details:
              type: object
              additionalProperties: true

  examples:
    MarkingResultZip:
      summary: Full ZIP marking result (one student shown)
      value:
        marking_job_id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
        model: examgpt-2.5-pro
        assessment:
          id: asm_01HZX9R2QK5Z7JX3F1H8GTA4PV
          title: AQA GCSE Maths Paper 1H - June 2024
          metadata:
            subject: maths
            exam_board: AQA
            course_level: GCSE
            difficulty_tier: higher
            paper_number: 1
            paper_tier: H
            year: 2024
            season: Summer
            spec_code: "8300"
            language: en-GB
            is_ofqual_regulated: true
            grade_boundaries: { "9": 80, "8": 71, "7": 62, "6": 53, "5": 44, "4": 35, "3": 26 }
          question_paper: { file_id: file_qp_01HZX9, page_count: 24 }
          marking_rubric: { file_id: file_ms_01HZX9, page_count: 16 }
          questions_count: 28
          status: ready
          created_at: "2026-05-08T12:21:04Z"
        summary:
          student_count: 32
          mean_score: 51.4
          median_score: 53
          grade_distribution: { "9": 2, "8": 4, "7": 5, "6": 7, "5": 6, "4": 5, "3": 2, "U": 1 }
        students:
          - student_id: stu_01HZX...J
            student_identifier: "00123456"
            student_name: "Jane Smith"
            external_ids:
              class_id: "Y11A"
              wonde_id: "wonde_stu_abc123"
            identification_source: cover_sheet_qr
            source_pdf_url: "https://public-api.examgpt.net/files/stu_01HZX...J.pdf?sig=...&exp=1715180000"
            page_range: [1, 12]
            total_marks_awarded: 47
            total_marks_possible: 80
            percentage: 58.75
            predicted_grade: "5"
            per_question:
              - question_id: q_01HZX9...B
                question_number: "1(b)"
                question_text: "Find the value of (8/125)^(-2/3)"
                max_marks: 2
                marks_awarded: 1
                marking_breakdown:
                  - { mark: "M1", awarded: true,  reason: "Correct first step shown: 25/4 oe" }
                  - { mark: "A1", awarded: false, reason: "Final answer not simplified to lowest terms" }
                examiner_feedback: "Good method, lost the accuracy mark on simplification. Show the final tidy answer."
                ocr: { text: "(25/4)^(?) = ...", confidence: 0.91 }
                page_numbers: [4]
                cropped_image_url: null
                confidence: 0.91
                requires_human_review: false
            pages:
              - page_number: 4
                page_width: 1654
                page_height: 2339
                bounding_boxes:
                  - { type: printed_question, bbox: { x1: 478, y1: 1878, x2: 1192, y2: 2037 }, confidence: 0.45 }
                  - { type: student_answer,   bbox: { x1: 493, y1: 2080, x2: 1418, y2: 2182 }, confidence: 0.38 }
            warnings: []
        usage: { ocr_pages: 384, marked_questions: 896, cost_credits: 312.5 }

  webhooks:
    markingJobQueued:
      post:
        summary: marking_job.queued
        requestBody:
          required: true
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEvent' }
              examples:
                queued:
                  value:
                    event: marking_job.queued
                    marking_job_id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    occurred_at: "2026-05-08T12:24:11Z"
                    data:
                      assessment_id: asm_01HZX9R2QK5Z7JX3F1H8GTA4PV
                      metadata: { school: "Acme High", cohort: "Y11-2024" }
        responses:
          '200': { description: Webhook processed. }
    markingJobProgress:
      post:
        summary: marking_job.progress
        requestBody:
          required: true
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEvent' }
              examples:
                progress:
                  value:
                    event: marking_job.progress
                    marking_job_id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    occurred_at: "2026-05-08T12:31:02Z"
                    data: { status: processing, progress: 47.5, completed_count: 15, student_count: 32 }
        responses:
          '200': { description: Webhook processed. }
    markingJobStudentCompleted:
      post:
        summary: marking_job.student_completed
        requestBody:
          required: true
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEvent' }
              examples:
                studentCompleted:
                  value:
                    event: marking_job.student_completed
                    marking_job_id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    occurred_at: "2026-05-08T12:33:14Z"
                    data:
                      student_id: stu_01HZX...J
                      student_identifier: "00123456"
                      total_marks_awarded: 47
                      total_marks_possible: 80
                      percentage: 58.75
                      predicted_grade: "5"
        responses:
          '200': { description: Webhook processed. }
    markingJobCompleted:
      post:
        summary: marking_job.completed
        requestBody:
          required: true
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEvent' }
              examples:
                completed:
                  value:
                    event: marking_job.completed
                    marking_job_id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    occurred_at: "2026-05-08T12:38:20Z"
                    data:
                      status: completed
                      result_url: "https://public-api.examgpt.net/v1/marking-jobs/mjb_01HZXA1B2C3D4E5F6G7H8J9K0L/results"
                      student_count: 32
                      completed_count: 32
                      failed_count: 0
        responses:
          '200': { description: Webhook processed. }
    markingJobFailed:
      post:
        summary: marking_job.failed
        requestBody:
          required: true
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEvent' }
              examples:
                failed:
                  value:
                    event: marking_job.failed
                    marking_job_id: mjb_01HZXA1B2C3D4E5F6G7H8J9K0L
                    occurred_at: "2026-05-08T12:38:20Z"
                    data:
                      status: failed
                      error:
                        code: qp_ms_question_count_mismatch
                        message: "Question paper has 28 questions but mark scheme has 27."
        responses:
          '200': { description: Webhook processed. }
