openapi: 3.1.0
info:
  title: Expert Hire Prep Public API
  version: "1.0.0"
  description: |
    Run AI interviews, resume analysis and assessments from your own product.

    Every request authenticates with one of four credentials, given per
    operation as `x-eh-credential`:

    - `secret_key` - your `ehp_sk_live_` or `ehp_sk_test_` key, server side only.
    - `session_jwt` - a short-lived browser credential minted from a secret key.
    - `launch_token` - a one-shot candidate link, the credential itself.
    - `public` - no credential.

    Keys carry their environment. A `_test_` key runs against sandbox: same code
    path and same response shapes, but it never records, never emails a
    candidate, caps interviews at five minutes, and draws on a separate monthly
    quota so testing can never spend live credits.
servers:
  - url: https://prep-api.experthire.cloud
    description: Production
  - url: https://staging-prep-api.experthire.cloud
    description: Staging

tags:
  - name: Meta
  - name: Sessions
  - name: Candidates
  - name: Resumes
  - name: Interviews
  - name: Coding tests
  - name: Prompt Engineering
  - name: Psychometric Test
  - name: Question bank
  - name: Webhooks

security:
  - SecretKey: []

paths:
  /v1/health:
    get:
      tags: [Meta]
      operationId: getHealth
      summary: Liveness probe
      security: []
      x-eh-credential: public
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: health }
                  status: { type: string, const: ok }

  /v1/.well-known/jwks.json:
    get:
      tags: [Meta]
      operationId: getJWKS
      summary: Session signing keys
      description: Public keys for verifying session tokens yourself. Every key that
        can still appear on a live token is listed, including one just rotated out.
      security: []
      x-eh-credential: public
      responses:
        "200":
          description: JSON Web Key Set
          content:
            application/json:
              schema: { type: object }

  /v1/openapi.yaml:
    get:
      tags: [Meta]
      operationId: getOpenAPISpec
      summary: This document
      description: Served by the running API, so it always describes the deployed build.
      security: []
      x-eh-credential: public
      responses:
        "200":
          description: The specification
          content:
            application/yaml:
              schema: { type: string }

  /v1/usage:
    get:
      tags: [Meta]
      operationId: getUsage
      summary: Credits, modules and limits
      description: Boot your integration off this rather than discovering 403s at
        runtime. Disabled modules are reported false, not omitted.
      x-eh-credential: secret_key
      responses:
        "200":
          description: Current usage
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions:
    post:
      tags: [Sessions]
      operationId: createSession
      summary: Mint a browser session
      description: |
        Exchanges your secret key for a short-lived token safe to expose in a
        browser. The origin must already be allowlisted for this environment.

        Never ship a secret key to a browser.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSessionRequest" }
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/sessions/refresh:
    post:
      tags: [Sessions]
      operationId: refreshSession
      summary: Refresh a session
      description: Call at `refresh_after`. Interviews outlive a single session, so
        a token that is never refreshed will expire mid-interview.
      x-eh-credential: session_jwt
      responses:
        "201":
          description: New session issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/session:
    get:
      tags: [Sessions]
      operationId: getSessionContext
      summary: Read the subject this session is scoped to
      description: |
        Everything the room needs to render before the candidate clicks Join:
        who they are, what round it is, how long it runs, and your branding.

        `subject_kind` is how the room learns what it was handed, and it serves
        all four: interview, coding, prompt_engineering and psychometric. Read it from here rather
        than from the URL, which the embedding page controls.

        Use this rather than `join` to show a waiting or terminal screen. Join is
        billable; this is not.
      x-eh-credential: session_jwt
      responses:
        "200":
          description: Session context
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionContext" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains:
    get:
      tags: [Sessions]
      operationId: listDomains
      summary: List allowlisted origins
      description: Origins allowlisted for the environment your key belongs to.
        Each entry records the key that added it.
      x-eh-credential: secret_key
      responses:
        "200":
          description: Domain list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Sessions]
      operationId: createDomain
      summary: Allowlist an origin
      description: |
        Adds an origin that may mint sessions and frame the room. Matching is
        exact on scheme, host and port, with no wildcards, so
        `https://app.example.com` and `https://app.example.com:8443` are
        different entries.

        `https` is required except on localhost. Repeating a call returns the
        existing entry rather than failing, so setup scripts are safe to re-run.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [origin]
              properties:
                origin: { type: string, example: "https://app.example.com" }
      responses:
        "201":
          description: Origin allowlisted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "200":
          description: Origin was already allowlisted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/domains/{id}:
    delete:
      tags: [Sessions]
      operationId: deleteDomain
      summary: Remove an allowlisted origin
      description: Existing sessions minted for this origin keep working until
        they expire; revoke them explicitly if that matters.
      x-eh-credential: secret_key
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Removed
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/embed-config:
    get:
      tags: [Sessions]
      operationId: getEmbedConfig
      summary: Resolve the embed allowlist and branding
      description: |
        Read by the room document itself to decide which parents may frame it,
        before any script runs. Authenticates on a publishable key in the query
        string, so it is safe in browser-reachable markup; it exposes nothing but
        the allowlist and your branding.
      x-eh-credential: publishable_key
      parameters:
        - name: pk
          in: query
          required: true
          schema: { type: string }
          description: A publishable key (`ehp_pk_live_…` or `ehp_pk_test_…`).
      responses:
        "200":
          description: Embed config
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EmbedConfig" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sessions/{jti}:
    delete:
      tags: [Sessions]
      operationId: revokeSession
      summary: Revoke a session
      x-eh-credential: secret_key
      parameters:
        - { name: jti, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Session revoked }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/launch/exchange:
    post:
      tags: [Sessions]
      operationId: exchangeLaunchToken
      summary: Redeem a candidate launch link
      description: |
        Trades a one-shot launch token for a session.

        Call this on a click, never on page load. Mail scanners and link
        previewers follow URLs, and a load-time exchange burns the token before
        the candidate sees the page.

        A valid token whose session is no longer workable is refused with a
        specific code rather than `invalid_launch_token`: `assignment_expired`
        (the assignment lapsed before the candidate opened it),
        `session_cancelled`, or `session_already_finalized`.
      security: []
      x-eh-credential: launch_token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400":
          description: The session this link points at is expired, cancelled or finished
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/candidates:
    get:
      tags: [Candidates]
      operationId: listCandidates
      summary: List candidates
      description: |
        Newest first. Pass `email` for an exact lookup, which is what you reach
        for when you know the person and have lost the id.
      x-eh-credential: secret_key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: starting_after
          in: query
          schema: { type: string, format: uuid }
        - { name: email, in: query, schema: { type: string, format: email } }
      responses:
        "200":
          description: Candidates
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Candidate" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Candidates]
      operationId: createCandidate
      summary: Create a candidate
      description: Candidates never sign in; you own the relationship. An address
        already belonging to an Expert Hire account is rejected.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, name]
              properties:
                email: { type: string, format: email }
                name: { type: string, maxLength: 255 }
      responses:
        "201":
          description: Candidate created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Candidate" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/resumes/upload-url:
    post:
      tags: [Resumes]
      operationId: createResumeUploadURL
      summary: Get a resume upload URL
      description: PUT the file directly to the returned URL, then pass the key when
        creating an interview.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [candidate_id, filename]
              properties:
                candidate_id: { type: string, format: uuid }
                filename: { type: string, maxLength: 250 }
      responses:
        "201":
          description: Presigned destination
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: resume_upload }
                  upload_url: { type: string, format: uri }
                  key: { type: string }
                  expires_in: { type: integer }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/resumes:
    get:
      tags: [Resumes]
      operationId: listResumes
      summary: List resumes
      description: |
        Newest first, keyset paginated. Pass `candidate_id` to narrow to one
        candidate. Page with `starting_after` set to the last id you saw.
      x-eh-credential: secret_key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: starting_after
          in: query
          schema: { type: string, format: uuid }
        - name: candidate_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Resume list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Resume" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Resumes]
      operationId: createResume
      summary: Register an uploaded resume
      description: |
        Parses a file already PUT to the presigned URL into a scoreable resume.

        Scoring is a separate call: it costs an LLM round trip and a credit, and
        most integrations want to choose the role first.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [candidate_id, key]
              properties:
                candidate_id: { type: string, format: uuid }
                key:
                  type: string
                  description: The `key` returned by /v1/resumes/upload-url.
      responses:
        "201":
          description: Resume parsed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Resume" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: The file could not be read as a resume

  /v1/resumes/{id}:
    get:
      tags: [Resumes]
      operationId: getResume
      summary: Retrieve a resume
      x-eh-credential: secret_key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Resume
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Resume" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Resumes]
      operationId: deleteResume
      summary: Delete a resume
      description: |
        Removes the record. The uploaded file is left in storage: an interview
        created from this resume still references its analysis.
      x-eh-credential: secret_key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: resume }
                  id: { type: string, format: uuid }
                  deleted: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/resumes/{id}/file:
    get:
      tags: [Resumes]
      operationId: getResumeFile
      summary: Download the original file
      description: |
        Returns a short-lived link rather than the bytes, so a large PDF never
        travels through the API. Mint a new one when it expires.
      x-eh-credential: secret_key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Signed download link
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: resume_file }
                  url: { type: string, format: uri }
                  file_name:
                    type: string
                    description: |
                      Present only for resumes uploaded through the first-party
                      app. Files registered over the API are stored under a
                      generated key and carry no original name.
                  expires_in: { type: integer, description: Seconds. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/resumes/{id}/score:
    post:
      tags: [Resumes]
      operationId: scoreResume
      summary: Score a resume
      description: |
        Runs the analysis and emits `resume.scored`.

        `role` and `job_description` are optional. Supply them and the resume is
        weighted against that brief; omit both and it is scored standalone. The
        brief is fingerprinted and cached, so re-scoring one resume against one
        brief is charged once.
      x-eh-credential: secret_key
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { type: string, example: "Backend Engineer" }
                job_description: { type: string }
      responses:
        "200":
          description: Scored
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Resume" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { description: Out of credits or sandbox quota }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/question-bank/companies:
    get:
      tags: [Question bank]
      operationId: listQuestionBankCompanies
      summary: List companies with questions
      x-eh-credential: secret_key
      responses:
        "200": { description: Company list }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/question-bank/categories:
    get:
      tags: [Question bank]
      operationId: listQuestionBankCategories
      summary: List categories
      x-eh-credential: secret_key
      responses:
        "200": { description: Category list }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/question-bank/topics:
    get:
      tags: [Question bank]
      operationId: listQuestionBankTopics
      summary: List topics
      x-eh-credential: secret_key
      responses:
        "200": { description: Topic list }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/question-bank/questions:
    get:
      tags: [Question bank]
      operationId: listQuestionBankQuestions
      summary: Search questions
      description: |
        Filter by `company`, `category`, `topic`, `difficulty`, `time_bucket`,
        `search` and `sort`. Reference data, so it costs no credit.

        Paged by an opaque `starting_after` cursor. Unlike interviews this is
        offset-backed, because the ordering is frequency and acceptance, which
        are not unique and shift when the bank refreshes.
      x-eh-credential: secret_key
      parameters:
        - { name: company, in: query, schema: { type: string } }
        - { name: category, in: query, schema: { type: string } }
        - { name: topic, in: query, schema: { type: string } }
        - { name: difficulty, in: query, schema: { type: string, enum: [easy, medium, hard] } }
        - { name: time_bucket, in: query, schema: { type: string, enum: [30d, 90d, 180d, all] } }
        - { name: search, in: query, schema: { type: string } }
        - { name: sort, in: query, schema: { type: string, enum: [frequency, difficulty, acceptance] } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
      responses:
        "200": { description: Question list }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/question-bank/questions/{id}:
    get:
      tags: [Question bank]
      operationId: getQuestionBankQuestion
      summary: Read one question
      x-eh-credential: secret_key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Question }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-tests:
    get:
      tags: [Coding tests]
      operationId: listCodingTests
      summary: List the coding test catalogue
      description: |
        Tests your organisation authored, plus the shared Expert Hire catalogue.
        Internal practice pools are not listed.
      x-eh-credential: secret_key
      parameters:
        - { name: skill_slug, in: query, schema: { type: string } }
        - name: difficulty
          in: query
          schema: { type: string, enum: [easy, medium, hard] }
      responses:
        "200":
          description: Coding test list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CodingTest" }
                  total_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/coding-tests/{id}:
    get:
      tags: [Coding tests]
      operationId: getCodingTest
      summary: Retrieve a coding test
      description: |
        Includes one representative problem per slot, so you can show the
        candidate what the test covers. A session draws its own variant per slot
        at assign time, so these are examples, not the problems that will be
        served. Hidden test cases, rubrics and expected complexities are never
        returned.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CodingTestID" }]
      responses:
        "200":
          description: The coding test
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingTestDetail" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions:
    get:
      tags: [Coding tests]
      operationId: listCodingSessions
      summary: List coding sessions
      description: |
        Newest first, keyset paginated. Page with `starting_after` set to the
        last id you saw rather than an offset.
      x-eh-credential: secret_key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: starting_after
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema:
            type: string
            enum: [assigned, started, in_progress, completed, timed_out, cancelled]
      responses:
        "200":
          description: Coding session list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CodingSession" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Coding tests]
      operationId: createCodingSession
      summary: Assign a coding test to a candidate
      description: |
        Reserves one coding credit and creates the session in `assigned`: the
        clock starts when the candidate opens it, not here.

        Idempotent by candidate and test. If the candidate already holds an
        assigned or unfinished session for the same test, that session is
        returned with `resumed: true` and nothing is charged, so a retry is
        always safe.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCodingSessionRequest" }
      responses:
        "200":
          description: An existing session was returned; no credit was spent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingSession" }
        "201":
          description: Coding session assigned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}:
    get:
      tags: [Coding tests]
      operationId: getCodingSession
      summary: Retrieve a coding session
      description: |
        The session with the problems it locked and, per problem, how many
        attempts are gone and whether it has been scored. A session token reads
        only the session it is bound to.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      responses:
        "200":
          description: The coding session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingSessionDetail" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/cancel:
    post:
      tags: [Coding tests]
      operationId: cancelCodingSession
      summary: Cancel a coding session
      description: |
        Releases an assignment the candidate never opened and returns the credit.

        Only a session still `assigned` can be cancelled: once it starts the
        credit is consumed, and this returns `session_already_started`. Calling
        it again on a cancelled session is a 200 that changes nothing.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      responses:
        "200":
          description: The cancelled session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/launch-link:
    post:
      tags: [Coding tests]
      operationId: createCodingLaunchLink
      summary: Create a candidate launch link for a coding session
      description: |
        Returns a hosted URL you can email. Single use, and the token is stored
        hashed, so this response is the only time you see it.

        While the session is still `assigned`, `expires_at` is capped to the end
        of the 7 day assignment window measured from `created_at`, so it can be
        earlier than the `ttl_seconds` you asked for. Past that point the
        assignment is expired, and minting a link is refused with
        `assignment_expired` rather than returning one that opens nothing.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_seconds: { type: integer, minimum: 300, maximum: 604800 }
                redirect_url: { type: string, format: uri }
      responses:
        "201":
          description: Launch link created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/report:
    get:
      tags: [Coding tests]
      operationId: getCodingSessionReport
      summary: Retrieve a coding session report
      description: |
        Available once the session is completed; before that this returns
        `report_not_ready` rather than a zero score.

        The report is the evaluator's judgement. The candidate's submitted
        source code is not part of it.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      responses:
        "200":
          description: The report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingReport" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/start:
    post:
      tags: [Coding tests]
      operationId: startCodingSession
      summary: Start the coding session
      description: |
        Moves the session from `assigned` to `started` and stamps the clock, then
        returns the problems it locked.

        Idempotent. A reload calls this again and gets the same session back with
        the original `started_at`: the deadline is never re-stamped, so reloading
        cannot buy extra time.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      responses:
        "200":
          description: The started session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingSessionDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/timer:
    get:
      tags: [Coding tests]
      operationId: getCodingSessionTimer
      summary: Read the remaining time
      description: |
        The server owns the clock. Count down locally between polls, but never
        upward: take the smaller of your local value and this one. `timed` is
        false for a test with no time limit, and `remaining_seconds` is then
        meaningless.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      responses:
        "200":
          description: Remaining time
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingTimer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/run:
    post:
      tags: [Coding tests]
      operationId: runCodingCode
      summary: Run code against the sample cases
      description: |
        Executes against the problem's sample cases, or against `stdin` when you
        send it. Nothing is scored and no attempt is consumed, so the editor can
        offer this freely.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CodingRunRequest" }
      responses:
        "200":
          description: Execution result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingRunResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { description: Code execution is temporarily unavailable }

  /v1/coding-sessions/{id}/submissions:
    post:
      tags: [Coding tests]
      operationId: createCodingSubmission
      summary: Submit a solution
      description: |
        Consumes one attempt and queues judging against every case, hidden ones
        included. Judging is asynchronous: poll the submission for the verdict.

        `remaining` is what is left of the attempt budget for that problem after
        this one.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CodingSubmissionRequest" }
      responses:
        "202":
          description: Submission queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingSubmissionReceipt" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    get:
      tags: [Coding tests]
      operationId: listCodingSubmissions
      summary: List attempts for one problem
      description: |
        Every attempt this session made on one problem, oldest first. Hidden
        cases carry pass/fail and status only.
      x-eh-credential: session_jwt
      parameters:
        - { $ref: "#/components/parameters/CodingSessionID" }
        - { name: problem_id, in: query, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Attempts
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CodingSubmission" }
                  total_count: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/submissions/{sid}:
    get:
      tags: [Coding tests]
      operationId: getCodingSubmission
      summary: Poll one submission
      description: |
        `status` is `queued`, `running`, `judged` or `error`; the verdict is
        `PENDING` until it is `judged`.
      x-eh-credential: session_jwt
      parameters:
        - { $ref: "#/components/parameters/CodingSessionID" }
        - { name: sid, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The submission
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingSubmission" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/events:
    post:
      tags: [Coding tests]
      operationId: recordCodingProctoringEvents
      summary: Record proctoring events
      description: |
        Browser proctoring reports signals, not enforcement. A completed session
        ignores them, and there is a cap per session past which `recorded` is 0.
        Use the desktop app when the result carries consequences.

        The event key is `type`. The interview endpoint accepts either `type` or
        `event`, so one proctoring client works against both. Each event may
        carry a free-form `meta` object, which only the assessment endpoints store.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssessmentEventsRequest" }
      responses:
        "202":
          description: Events recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: proctoring_events }
                  recorded: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/coding-sessions/{id}/finalize:
    post:
      tags: [Coding tests]
      operationId: finalizeCodingSession
      summary: End the test and score it
      description: |
        Scores every problem and completes the session. Scoring is asynchronous,
        so `status` may still be `in_progress` when this returns; the report
        arrives on `coding.completed`.

        `coding.completed` also fires for a session abandoned past its time
        limit, which we finalize for you. Read `status` in the payload: it is
        `timed_out` rather than `completed` for those.

        Returns `judging_in_progress` (409) while a fresh submission is still
        being judged. Retry in a few seconds rather than treating it as failure:
        scoring around it would drop the attempt the candidate just made.

        Safe to call more than once.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CodingSessionID" }]
      responses:
        "200":
          description: Finalization started, or the session was already complete
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodingFinalization" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: A submission is still being judged }

  /v1/prompt-engineering-assessments:
    get:
      tags: [Prompt Engineering]
      operationId: listFowAssessments
      summary: List the Prompt Engineering assessment catalogue
      description: |
        Assessment packs your organisation authored, plus the shared Expert Hire
        catalogue.
      x-eh-credential: secret_key
      parameters:
        - { name: skill_slug, in: query, schema: { type: string } }
        - name: mode
          in: query
          schema: { type: string, enum: [tech, non_tech] }
      responses:
        "200":
          description: Assessment list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/FowAssessment" }
                  total_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/prompt-engineering-assessments/{id}:
    get:
      tags: [Prompt Engineering]
      operationId: getFowAssessment
      summary: Retrieve a Prompt Engineering assessment
      description: |
        Includes one representative task per slot, so you can show the candidate
        what the assessment covers. A session draws its own variant per slot at
        assign time, so these are examples, not the tasks that will be served.
        Scoring rubrics and evaluator prompts are never returned.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/FowAssessmentID" }]
      responses:
        "200":
          description: The assessment
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowAssessmentDetail" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions:
    get:
      tags: [Prompt Engineering]
      operationId: listFowSessions
      summary: List Prompt Engineering sessions
      description: |
        Newest first, keyset paginated. Page with `starting_after` set to the
        last id you saw rather than an offset.
      x-eh-credential: secret_key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: starting_after
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema:
            type: string
            enum: [assigned, started, in_progress, completed, timed_out, cancelled]
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/FowSession" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Prompt Engineering]
      operationId: createFowSession
      summary: Assign a Prompt Engineering assessment to a candidate
      description: |
        Reserves one Prompt Engineering credit and creates the session in `assigned`:
        the clock starts when the candidate opens it, not here.

        Idempotent by candidate and assessment. If the candidate already holds an
        assigned or unfinished session for the same assessment, that session is
        returned with `resumed: true` and nothing is charged, so a retry is
        always safe.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateFowSessionRequest" }
      responses:
        "200":
          description: An existing session was returned; no credit was spent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowSession" }
        "201":
          description: Session assigned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}:
    get:
      tags: [Prompt Engineering]
      operationId: getFowSession
      summary: Retrieve a Prompt Engineering session
      description: |
        The session with the tasks it locked, the results scored so far and, per
        task, how many attempts are gone. A session token reads only the session
        it is bound to.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      responses:
        "200":
          description: The session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowSessionDetail" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/cancel:
    post:
      tags: [Prompt Engineering]
      operationId: cancelFowSession
      summary: Cancel a Prompt Engineering session
      description: |
        Releases an assignment the candidate never opened and returns the credit.

        Only a session still `assigned` can be cancelled: once it starts the
        credit is consumed, and this returns `session_already_started`. Calling
        it again on a cancelled session is a 200 that changes nothing.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      responses:
        "200":
          description: The cancelled session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/launch-link:
    post:
      tags: [Prompt Engineering]
      operationId: createFowLaunchLink
      summary: Create a candidate launch link for a Prompt Engineering session
      description: |
        Returns a hosted URL you can email. Single use, and the token is stored
        hashed, so this response is the only time you see it.

        While the session is still `assigned`, `expires_at` is capped to the end
        of the 7 day assignment window measured from `created_at`, so it can be
        earlier than the `ttl_seconds` you asked for. Past that point the
        assignment is expired, and minting a link is refused with
        `assignment_expired` rather than returning one that opens nothing.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_seconds: { type: integer, minimum: 300, maximum: 604800 }
                redirect_url: { type: string, format: uri }
      responses:
        "201":
          description: Launch link created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/report:
    get:
      tags: [Prompt Engineering]
      operationId: getFowSessionReport
      summary: Retrieve a Prompt Engineering session report
      description: |
        Available once the session is completed; before that this returns
        `report_not_ready` rather than a zero score.

        The report is the evaluator's judgement. The candidate's prompts and the
        model output they produced are not part of it.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      responses:
        "200":
          description: The report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowReport" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/start:
    post:
      tags: [Prompt Engineering]
      operationId: startFowSession
      summary: Start the Prompt Engineering session
      description: |
        Moves the session from `assigned` to `started` and stamps the clock, then
        returns the tasks it locked.

        Idempotent. A reload calls this again and gets the same session back with
        the original `started_at`: the deadline is never re-stamped, so reloading
        cannot buy extra time.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      responses:
        "200":
          description: The started session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowSessionDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/timer:
    get:
      tags: [Prompt Engineering]
      operationId: getFowSessionTimer
      summary: Read the remaining time
      description: |
        The server owns the clock. Count down locally between polls, but never
        upward: take the smaller of your local value and this one. `timed` is
        false for an assessment with no time limit, and `remaining_seconds` is
        then meaningless.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      responses:
        "200":
          description: Remaining time
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowTimer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/attempts:
    post:
      tags: [Prompt Engineering]
      operationId: createFowAttempt
      summary: Submit a prompt
      description: |
        Consumes one attempt on a task. Generation and scoring are asynchronous:
        poll the attempt for the verdict.

        `remaining` is what is left of the attempt budget for that task after
        this one.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FowAttemptRequest" }
      responses:
        "202":
          description: Attempt queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowAttemptReceipt" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    get:
      tags: [Prompt Engineering]
      operationId: listFowAttempts
      summary: List attempts for one task
      description: |
        Every attempt this session made on one task, oldest first, with the
        task's result once it has been scored.
      x-eh-credential: session_jwt
      parameters:
        - { $ref: "#/components/parameters/FowSessionID" }
        - { name: task_id, in: query, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Attempts
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/FowAttempt" }
                  total_count: { type: integer }
                  result:
                    oneOf:
                      - { $ref: "#/components/schemas/FowTaskResult" }
                      - { type: "null" }
                    description: Null until the task has been scored.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/attempts/{aid}:
    get:
      tags: [Prompt Engineering]
      operationId: getFowAttempt
      summary: Poll one attempt
      description: |
        `status` moves `submitted` to `generating` to `evaluating` to `scored`.
        Stop polling on `scored` or `error`; nothing else is terminal.
      x-eh-credential: session_jwt
      parameters:
        - { $ref: "#/components/parameters/FowSessionID" }
        - { name: aid, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The attempt
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowAttempt" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/events:
    post:
      tags: [Prompt Engineering]
      operationId: recordFowProctoringEvents
      summary: Record proctoring events
      description: |
        Browser proctoring reports signals, not enforcement. A completed session
        ignores them, and there is a cap per session past which `recorded` is 0.
        Use the desktop app when the result carries consequences.

        The event key is `type`. The interview endpoint accepts either `type` or
        `event`, so one proctoring client works against both. Each event may
        carry a free-form `meta` object, which only the assessment endpoints store.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssessmentEventsRequest" }
      responses:
        "202":
          description: Events recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: proctoring_events }
                  recorded: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/prompt-engineering-sessions/{id}/finalize:
    post:
      tags: [Prompt Engineering]
      operationId: finalizeFowSession
      summary: End the assessment and score it
      description: |
        Aborts attempts still in flight, scores every task that has attempts and
        scores the rest zero. Scoring is asynchronous, so `status` may still be
        `in_progress` when this returns; the report arrives on `prompt_engineering.completed`.

        `prompt_engineering.completed` also fires for a session abandoned past its time limit,
        which we finalize for you. Those are scored the same way and arrive as
        `completed`, with zero for every task that had no attempts. `timed_out`
        means the assignment was never opened at all, and no report exists.

        Safe to call more than once.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/FowSessionID" }]
      responses:
        "200":
          description: Finalization started, or the session was already complete
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FowFinalization" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-tests:
    get:
      tags: [Psychometric Test]
      operationId: listCareerDNATests
      summary: List the psychometric instruments
      description: |
        Instruments your organisation authored, plus the shared Expert Hire
        catalogue.
      x-eh-credential: secret_key
      responses:
        "200":
          description: Instrument list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CareerDNATest" }
                  total_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/psychometric-tests/{id}:
    get:
      tags: [Psychometric Test]
      operationId: getCareerDNATest
      summary: Retrieve a psychometric instrument
      description: |
        Metadata only. The items are served to the candidate on their session
        token and are never returned here: this is one fixed validated
        instrument, so anyone holding it could coach against it and there is no
        second form to fall back on.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CareerDNATestID" }]
      responses:
        "200":
          description: The instrument
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNATest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions:
    get:
      tags: [Psychometric Test]
      operationId: listCareerDNASessions
      summary: List psychometric sessions
      description: |
        Newest first, keyset paginated. Page with `starting_after` set to the
        last id you saw rather than an offset.
      x-eh-credential: secret_key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: starting_after
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema:
            type: string
            enum: [assigned, started, in_progress, scoring, completed, timed_out, cancelled]
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CareerDNASession" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Psychometric Test]
      operationId: createCareerDNASession
      summary: Assign a psychometric test to a candidate
      description: |
        Reserves one Psychometric Test credit and creates the session in `assigned`: the
        candidate opens it later, and the elapsed time the response-quality
        checks read runs from the start call, not from here.

        Idempotent by candidate and instrument. If the candidate already holds an
        assigned or unfinished session for the same instrument, that session is
        returned with `resumed: true` and nothing is charged, so a retry is
        always safe.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCareerDNASessionRequest" }
      responses:
        "200":
          description: An existing session was returned; no credit was spent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNASession" }
        "201":
          description: Session assigned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNASession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions/{id}:
    get:
      tags: [Psychometric Test]
      operationId: getCareerDNASession
      summary: Retrieve the instrument and the answers saved so far
      description: |
        The candidate's own view: the items, the scenes they are staged in, and
        whatever has been saved. Options carry a key and a label only; the
        dimension, weight, reverse flag and contribs are the scoring key and are
        never served.

        A session token reads only the session it is bound to. There is no
        secret-key form of this call: the item text belongs to the candidate.

        A dead session serves nothing: `assignment_expired` once the assignment
        has lapsed unopened, `session_cancelled` once it has been cancelled.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CareerDNASessionID" }]
      responses:
        "200":
          description: The session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNASessionDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions/{id}/cancel:
    post:
      tags: [Psychometric Test]
      operationId: cancelCareerDNASession
      summary: Cancel a psychometric session
      description: |
        Releases an assignment the candidate never opened and returns the credit.

        Only a session still `assigned` can be cancelled: once it starts the
        credit is consumed, and this returns `session_already_started`. Calling
        it again on a cancelled session is a 200 that changes nothing.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CareerDNASessionID" }]
      responses:
        "200":
          description: The cancelled session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNASession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions/{id}/launch-link:
    post:
      tags: [Psychometric Test]
      operationId: createCareerDNALaunchLink
      summary: Create a candidate launch link for a psychometric session
      description: |
        Returns a hosted URL you can email. Single use, and the token is stored
        hashed, so this response is the only time you see it.

        While the session is still `assigned`, `expires_at` is capped to the end
        of the 7 day assignment window measured from `created_at`, so it can be
        earlier than the `ttl_seconds` you asked for. Past that point the
        assignment is expired, and minting a link is refused with
        `assignment_expired` rather than returning one that opens nothing.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CareerDNASessionID" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_seconds: { type: integer, minimum: 300, maximum: 604800 }
                redirect_url: { type: string, format: uri }
      responses:
        "201":
          description: Launch link created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchLink" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions/{id}/start:
    post:
      tags: [Psychometric Test]
      operationId: startCareerDNASession
      summary: Start the psychometric session
      description: |
        Moves the session from `assigned` to `started` and begins the elapsed-time
        measurement the response-quality checks read, then returns the instrument.

        There is no deadline: the measurement reports how the answers were given,
        it does not end the session. Idempotent, and a reload keeps the original
        start time rather than reporting the whole instrument as answered in a
        few seconds.

        An assignment left unopened for 7 days is expired and refused with
        `assignment_expired`; assign a new session.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CareerDNASessionID" }]
      responses:
        "200":
          description: The started session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNASessionDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions/{id}/progress:
    post:
      tags: [Psychometric Test]
      operationId: saveCareerDNAProgress
      summary: Save answers
      description: |
        Autosave. Send the whole answers map each time; it replaces what was
        stored.

        A save that lands after the submit returns `ignored: true` rather than an
        error, because a room saving on a timer will routinely send one behind
        the button.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CareerDNASessionID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CareerDNAAnswers" }
      responses:
        "200":
          description: Saved, or ignored because the session was already submitted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNAProgress" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions/{id}/submit:
    post:
      tags: [Psychometric Test]
      operationId: submitCareerDNASession
      summary: Submit the instrument and score it
      description: |
        Scoring is deterministic, synchronous and durable before this returns:
        the session is `scoring` and the report can be read immediately.
        `psychometric.completed` fires on the same write.

        Only the written profile is asynchronous. It arrives later, raises
        `narrative_version` above zero and fires `psychometric.report_ready`.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/CareerDNASessionID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CareerDNAAnswers" }
      responses:
        "200":
          description: Scored
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNASubmission" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/psychometric-sessions/{id}/report:
    get:
      tags: [Psychometric Test]
      operationId: getCareerDNASessionReport
      summary: Retrieve a psychometric report
      description: |
        Available from the submit, while the session is still `scoring`: the
        deterministic scores are the report and the written profile is prose on
        top of them. Read `narrative_ready` before quoting `summary`, which
        carries a placeholder until the profile lands. Before the submit this
        returns `report_not_ready`.

        This is a self-report questionnaire with no right answers. It is not a
        hiring or shortlisting signal and must not be used to rank or screen
        anyone; `disclaimer` and `roles_disclaimer` carry that wording and are
        meant to be displayed. What a recruiter sees is deliberately narrower
        than what the candidate sees: evidence counts, response quality,
        per-role gaps, skills to build and the raw answers are not part of it.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/CareerDNASessionID" }]
      responses:
        "200":
          description: The report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CareerDNAReport" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews:
    get:
      tags: [Interviews]
      operationId: listInterviews
      summary: List interviews
      description: |
        Newest first, keyset paginated. Page with `starting_after` set to the
        last id you saw rather than an offset, so a page cannot skip or repeat a
        row while interviews are being created underneath you.
      x-eh-credential: secret_key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: starting_after
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          description: Filter by interview status code.
          schema: { type: integer }
      responses:
        "200":
          description: Interview list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Interview" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Interviews]
      operationId: createInterview
      summary: Create an interview
      description: Reserves one interview credit. Cancelling before the candidate
        joins returns it.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateInterviewRequest" }
      responses:
        "201":
          description: Interview created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Interview" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/interviews/slots:
    get:
      tags: [Interviews]
      operationId: listInterviewSlots
      summary: List bookable slots
      description: |
        How many interviews can still start in each half-hour, so you schedule
        into capacity instead of finding out at join time. `from` and `to` are
        unix seconds; the default window is the next 24 hours and the maximum is
        14 days. Pass `candidate_id` to omit slots that candidate already holds.
      x-eh-credential: secret_key
      parameters:
        - { name: from, in: query, schema: { type: integer, format: int64 } }
        - { name: to, in: query, schema: { type: integer, format: int64 } }
        - { name: candidate_id, in: query, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Slot list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        start_time: { type: integer, format: int64 }
                        available: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/interviews/{id}:
    get:
      tags: [Interviews]
      operationId: getInterview
      summary: Retrieve an interview
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200":
          description: The interview
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Interview" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/report:
    get:
      tags: [Interviews]
      operationId: getInterviewReport
      summary: Retrieve the report
      description: |
        Available once the interview reaches `Completed`. Before that this is a
        404 with code `report_not_ready`, naming the current status, rather than
        a zero-scored report for an interview that has not been scored.

        Media URLs are signed and expire in two hours. A field is absent when the
        artefact was never produced, which is normal for an interview that ended
        early.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200":
          description: The report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InterviewReport" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/transcript:
    get:
      tags: [Interviews]
      operationId: getInterviewTranscript
      summary: Retrieve the transcript
      description: |
        The transcript as ordered entries, so you never parse our storage
        format. The report also carries a signed link to the raw file; this is
        the same content, already decoded.

        Written when the interview finishes processing. Before then this is a
        404 with code `transcript_not_ready`, which is a retry, not a
        missing interview.
      x-eh-credential: secret_key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Transcript entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        at: { type: string, description: "Offset from the recording start, H:MM:SS." }
                        speaker: { type: string }
                        text: { type: string }
                  total_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/recording-url:
    get:
      tags: [Interviews]
      operationId: getInterviewRecordingURL
      summary: Get a fresh recording link
      description: |
        Mints a new signed link. The report carries one too, but it expires, and
        re-fetching a whole report to refresh a URL is the wrong shape for a
        caller that stored the report days ago.
      x-eh-credential: secret_key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Signed recording link
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: recording }
                  url: { type: string, format: uri }
                  expires_in: { type: integer, description: Seconds. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/cancel:
    post:
      tags: [Interviews]
      operationId: cancelInterview
      summary: Cancel an interview
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200":
          description: Interview cancelled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Interview" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/launch-link:
    post:
      tags: [Interviews]
      operationId: createLaunchLink
      summary: Create a candidate launch link
      description: Returns a hosted URL you can email. Single use, and the token is
        stored hashed, so this response is the only time you see it.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_seconds: { type: integer, minimum: 300, maximum: 604800 }
                redirect_url: { type: string, format: uri }
      responses:
        "201":
          description: Launch link created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchLink" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/join:
    post:
      tags: [Interviews]
      operationId: joinInterview
      summary: Join the interview room
      description: |
        Returns the realtime credentials and starts the interview: it flips the
        status live, dispatches the AI interviewer and begins recording.

        This is the billable moment. Call it once, when the candidate is actually
        ready. `realtime.channel` is opaque; do not construct, parse or store it.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200":
          description: Join granted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JoinGrant" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/end:
    post:
      tags: [Interviews]
      operationId: endInterview
      summary: End the interview
      description: |
        Closes the room, stops the AI interviewer and stops recording. Scoring
        runs afterwards, so the interview moves to `processing`, not `completed`,
        and the report arrives on `interview.summary_available`.

        Safe to call more than once.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200":
          description: Interview ending
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Interview" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/timer:
    get:
      tags: [Interviews]
      operationId: getInterviewTimer
      summary: Read the remaining time
      description: |
        The server owns the clock. Count down locally between polls, but never
        upward: take the smaller of your local value and this one.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200":
          description: Remaining time
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Timer" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/candidates/{id}:
    get:
      tags: [Candidates]
      operationId: getCandidate
      summary: Retrieve a candidate
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200": { description: The candidate, content: { application/json: { schema: { $ref: "#/components/schemas/Candidate" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Candidates]
      operationId: updateCandidate
      summary: Correct a candidate's email or name
      description: |
        Only a candidate you created through the API can be edited. One who has
        since signed in to Expert Hire owns their own profile, and rewriting
        their email would hand someone else their sign-in: that returns
        `candidate_not_editable`.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                name: { type: string, maxLength: 255 }
      responses:
        "200": { description: Updated, content: { application/json: { schema: { $ref: "#/components/schemas/Candidate" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Candidates]
      operationId: deleteCandidate
      summary: Erase a candidate
      description: |
        De-identifies the person rather than dropping the row: interviews and
        reports carry the candidate id, and removing it would break your own
        organisation's aggregates. Name, email, phone and every profile field are
        overwritten and the record can no longer be tied to a person. This is not
        reversible.

        A candidate who has signed in to Expert Hire owns their own deletion and
        returns `candidate_not_editable`.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200":
          description: Erased
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [candidate] }
                  id: { type: string, format: uuid }
                  deleted: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/round-types:
    get:
      tags: [Meta]
      operationId: listRoundTypes
      summary: List the round types you can book
      description: |
        The set `POST /v1/interviews` accepts, with the capability flags that tell
        you whether to render a code editor or a whiteboard. `daily_english` is
        excluded: it is the student app's own drill and is not bookable here.
      x-eh-credential: secret_key
      responses:
        "200":
          description: Round types
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RoundTypeInfo" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/companies:
    get:
      tags: [Meta]
      operationId: listCompanies
      summary: List companies available to your organisation
      x-eh-credential: secret_key
      responses:
        "200":
          description: Companies
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CompanyInfo" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/jobs:
    get:
      tags: [Meta]
      operationId: listJobs
      summary: List job descriptions available to your organisation
      x-eh-credential: secret_key
      parameters:
        - { name: category, in: query, schema: { type: string }, description: Omit to list every category. }
      responses:
        "200":
          description: Jobs
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/JobInfo" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/coding-languages:
    get:
      tags: [Meta]
      operationId: listCodingLanguages
      summary: List languages the execution sandbox runs
      description: |
        What `POST /v1/coding-sessions/{id}/run` and `/submissions` will actually
        execute. A problem may narrow this further with its own allowed list.
      x-eh-credential: secret_key
      responses:
        "200":
          description: Languages
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CodingLanguage" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/webhook-endpoints/{id}:
    get:
      tags: [Webhooks]
      operationId: getWebhookEndpoint
      summary: Retrieve a webhook endpoint
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200": { description: The endpoint, content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Change an endpoint's URL, filter or state
      description: |
        Changing `enabled_events` replaces the filter outright; send the full set,
        not a delta. An empty array means every event. `disabled` parks an
        endpoint without losing its secret or its delivery history, which is what
        you want during an incident on your side.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                description: { type: string, maxLength: 255 }
                enabled_events: { type: array, items: { type: string } }
                disabled: { type: boolean }
      responses:
        "200": { description: Updated, content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200": { description: Endpoint deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhook-endpoints/{id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhookEndpoint
      summary: Fire a test event at an endpoint
      description: |
        Queues a `webhook.test` event at this endpoint, ignoring its event filter:
        you are proving the receiver answers and your signature check passes, not
        that routing works. It is attempted once rather than retried for six
        hours, so a failure shows up immediately in
        `GET /v1/webhook-endpoints/{id}/deliveries`.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "202":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [webhook_test] }
                  endpoint_id: { type: string, format: uuid }
                  event_id: { type: string }
                  type: { type: string, enum: [webhook.test] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhook-endpoints/{id}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Read an endpoint's attempt history
      description: |
        Every attempt, newest first, with the response status and body we saw.
        Request headers are never returned: they carry the signature. Response
        bodies are truncated. Retention matches the 30-day event sweep, so this is
        a debugging aid and not a compliance archive.
      x-eh-credential: secret_key
      parameters:
        - { $ref: "#/components/parameters/InterviewID" }
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        "200":
          description: Deliveries
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/events:
    get:
      tags: [Webhooks]
      operationId: listEvents
      summary: List events queued for your endpoints
      description: |
        Scoped to the calling key's environment, newest first. `payload` is the
        exact body that was signed and sent, so a signature mismatch can be
        debugged against the same bytes we hashed.
      x-eh-credential: secret_key
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: starting_after
          in: query
          schema: { type: string, format: uuid }
        - { name: type, in: query, schema: { type: string }, description: Filter by event type. }
        - { name: status, in: query, schema: { type: string, enum: [pending, processing, delivered, failed, retrying] } }
        - { name: endpoint_id, in: query, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
                  has_more: { type: boolean }
                  next_cursor: { type: string, format: uuid }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/events/{id}:
    get:
      tags: [Webhooks]
      operationId: getEvent
      summary: Retrieve one event
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "200": { description: The event, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/events/{id}/redeliver:
    post:
      tags: [Webhooks]
      operationId: redeliverEvent
      summary: Replay a failed event
      description: |
        Puts a failed event back on the queue with a fresh attempt budget. Only a
        failed one qualifies: replaying a delivered event would double-fire a
        receiver that already acted on it, and one still retrying does not need
        your help. Anything else returns `event_not_replayable`.

        Events are swept after 30 days, so this is a recovery tool for an outage
        on your side, not an archive.
      x-eh-credential: secret_key
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      responses:
        "202": { description: Requeued, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/code:
    post:
      tags: [Interviews]
      operationId: submitInterviewCode
      summary: Save the candidate's code
      description: |
        For a round whose join grant reports `supports_code_editor`. Last write
        wins, so save periodically as well as on submit: a partial answer scores
        better than nothing if the candidate runs out of time.

        This stores code for the interviewer to see and the scorer to read. It
        does not execute anything. For graded execution against test cases, use
        a coding test.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
                language:
                  type: string
                  maxLength: 32
                  description: Editor mode name, e.g. `python`, `c_cpp`, `golang`. Omitting it leaves a stored language alone.
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [interview_code] }
                  interview_id: { type: string, format: uuid }
                  language: { type: string }
                  bytes: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/board-upload-url:
    post:
      tags: [Interviews]
      operationId: createBoardUploadURL
      summary: Presign an upload for the whiteboard image
      description: |
        For a round whose join grant reports `supports_whiteboard`. PUT the image
        to the returned URL with the same `Content-Type` and byte length you
        declared. The key is fixed per interview, so a re-upload replaces the
        previous board rather than adding a second one.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_type, size_bytes]
              properties:
                content_type: { type: string, enum: [image/png, image/jpeg, image/webp] }
                size_bytes: { type: integer, minimum: 1, maximum: 10485760 }
      responses:
        "201":
          description: Presigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [board_upload_url] }
                  url: { type: string, format: uri }
                  expires_at: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/interviews/{id}/events:
    post:
      tags: [Interviews]
      operationId: recordProctoringEvents
      summary: Record proctoring events
      description: |
        Browser proctoring reports signals, not enforcement. Tab visibility and
        paste are reliable; multiple-monitor detection requires a permission the
        candidate can decline, so its absence is not evidence of one screen.
        Use the desktop app when the result carries consequences.
      x-eh-credential: session_jwt
      parameters: [{ $ref: "#/components/parameters/InterviewID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [events]
              properties:
                events:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items:
                    type: object
                    required: [event, timestamp]
                    properties:
                      event: { type: string, maxLength: 64 }
                      timestamp: { type: integer }
      responses:
        "202": { description: Events recorded }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/webhook-endpoints:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      x-eh-credential: secret_key
      responses:
        "200":
          description: Your endpoints
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Register a webhook endpoint
      description: |
        Register as many as you need; filter each with `enabled_events`. An empty
        filter receives everything.

        The signing secret is returned once. Verify `Eh-Signature` against the
        raw request body, before any JSON parsing.
      x-eh-credential: secret_key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                description: { type: string, maxLength: 255 }
                enabled_events:
                  type: array
                  items: { type: string, maxLength: 64 }
      responses:
        "201":
          description: Endpoint registered
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/webhook-endpoints/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: The previous secret keeps verifying for 24 hours, so you can roll
        without dropping deliveries. Both signatures appear in `Eh-Signature`
        during the window; accept a delivery if either matches.
      x-eh-credential: secret_key
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Secret rotated
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    SecretKey:
      type: http
      scheme: bearer
      description: Your `ehp_sk_live_` or `ehp_sk_test_` key. `X-API-KEY` also works.
    SessionToken:
      type: http
      scheme: bearer
      description: A session token from `POST /v1/sessions`.

  parameters:
    InterviewID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    CodingTestID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    CodingSessionID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    FowAssessmentID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    FowSessionID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    CareerDNATestID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    CareerDNASessionID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: The request was malformed or failed validation
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, invalid or revoked credential
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Authenticated, but not permitted
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: |
        No such resource. Also returned when the resource exists but belongs to
        another organisation, so the API never confirms an id you cannot access.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PaymentRequired:
      description: Out of credits, or the sandbox quota for this month is exhausted
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          required: [type, code, message, doc_url]
          properties:
            type:
              type: string
              enum:
                - authentication_error
                - permission_error
                - invalid_request_error
                - not_found_error
                - rate_limit_error
                - idempotency_error
                - insufficient_credits
                - capacity_error
                - api_error
            code: { type: string }
            message: { type: string }
            param: { type: string }
            request_id: { type: string }
            doc_url: { type: string, format: uri }

    Candidate:
      type: object
      properties:
        object: { type: string, const: candidate }
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        name: { type: string }
        created_at: { type: integer }

    CreateSessionRequest:
      type: object
      required: [origin]
      properties:
        origin:
          type: string
          format: uri
          description: Exact browser origin, matched on scheme, host and port. No wildcards.
        interview_id:
          type: string
          format: uuid
          description: Binds the session to one interview. Strongly recommended.

    Session:
      type: object
      properties:
        object: { type: string, const: session }
        session_token: { type: string }
        jti: { type: string, format: uuid }
        expires_at: { type: integer }
        refresh_after:
          type: integer
          description: Refresh at this time; do not wait for expiry.
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }

    CreateInterviewRequest:
      type: object
      required: [round_type, difficulty, role]
      properties:
        candidate_id: { type: string, format: uuid }
        candidate:
          type: object
          description: Create the candidate inline instead of passing candidate_id.
          required: [email, name]
          properties:
            email: { type: string, format: email }
            name: { type: string }
        round_type:
          type: string
          maxLength: 64
          description: |
            Must be a round your organisation has. An unknown slug is rejected
            with `invalid_round_type` and the error lists what is available.
        difficulty: { type: integer, minimum: 1, maximum: 5 }
        role: { type: string, maxLength: 255 }
        job_id: { type: string, format: uuid }
        resume_id:
          type: string
          format: uuid
          description: |
            Optional. Attaches a scored resume so the interviewer can reference
            it. The resume must belong to the same candidate.
        scheduled_at: { type: integer }

    Interview:
      type: object
      properties:
        object: { type: string, const: interview }
        id: { type: string, format: uuid }
        status: { type: string }
        candidate_id: { type: string, format: uuid }
        round_type: { type: string }
        difficulty: { type: integer }
        role: { type: string }
        mode: { type: string }
        scheduled_at: { type: integer }
        started_at: { type: integer }
        ended_at: { type: integer }
        report_available: { type: boolean }
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }
        created_at: { type: integer }

    CodingTest:
      type: object
      properties:
        object: { type: string, const: coding_test }
        id: { type: string, format: uuid }
        title: { type: string }
        slug: { type: string }
        description: { type: string }
        difficulty: { type: string, enum: [easy, medium, hard] }
        skill_slug: { type: string }
        skill_name: { type: string }
        time_limit_minutes:
          type: integer
          description: Absent on an untimed test.
        max_attempts_per_problem: { type: integer }
        allowed_languages:
          type: array
          items: { type: string }
          description: Empty means every supported language is allowed.
        created_at: { type: integer }

    CodingProblem:
      type: object
      description: |
        The candidate-facing view of a problem. Rubrics, expected complexities,
        reference solutions and hidden test cases are never included.
      properties:
        object: { type: string, const: coding_problem }
        id: { type: string, format: uuid }
        title: { type: string }
        statement: { type: string }
        constraints: { type: string }
        input_format: { type: string }
        output_format: { type: string }
        difficulty: { type: string }
        category: { type: string }
        slot:
          type: integer
          description: Variant group. One problem per slot is served per session.
        time_limit_ms: { type: integer }
        memory_limit_kb: { type: integer }
        max_attempts: { type: integer }
        topic_tags:
          type: array
          items: { type: string }
        starter_code:
          type: object
          additionalProperties: { type: string }
        sample_cases:
          type: array
          description: The published examples, and only those. Hidden cases are
            the answer key and never appear here.
          items:
            type: object
            properties:
              stdin: { type: string }
              expected_stdout: { type: string }

    CodingTestDetail:
      allOf:
        - $ref: "#/components/schemas/CodingTest"
        - type: object
          properties:
            problems:
              type: array
              items: { $ref: "#/components/schemas/CodingProblem" }

    CreateCodingSessionRequest:
      type: object
      required: [test_id]
      properties:
        candidate_id: { type: string, format: uuid }
        candidate:
          type: object
          description: Create the candidate inline instead of passing candidate_id.
          required: [email, name]
          properties:
            email: { type: string, format: email }
            name: { type: string }
        test_id: { type: string, format: uuid }

    CodingSession:
      type: object
      properties:
        object: { type: string, const: coding_session }
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [assigned, started, in_progress, completed, timed_out, cancelled]
        candidate_id: { type: string, format: uuid }
        test_id: { type: string, format: uuid }
        test_title: { type: string }
        started_at:
          type: integer
          description: Absent until the candidate opens the session.
        completed_at: { type: integer }
        overall_score: { type: number }
        readiness_band:
          type: string
          enum: [beginner, emerging, job_ready, advanced]
        report_available: { type: boolean }
        resumed:
          type: boolean
          description: True when an assign returned a session that already existed.
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }
        created_at: { type: integer }

    CodingSessionDetail:
      allOf:
        - $ref: "#/components/schemas/CodingSession"
        - type: object
          properties:
            problems:
              type: array
              items: { $ref: "#/components/schemas/CodingProblem" }
            progress:
              type: array
              items:
                type: object
                properties:
                  problem_id: { type: string, format: uuid }
                  attempts_used: { type: integer }
                  max_attempts: { type: integer }
                  has_result: { type: boolean }
            allowed_languages:
              type: array
              items: { type: string }
              description: The test's allow-list narrowed to what the executor
                installs, so it can never offer a language a submission would
                refuse. Build the language picker from this.
            expires_at:
              type: integer
              description: Absent on an untimed test and before the candidate starts.

    CodingTimer:
      type: object
      properties:
        object: { type: string, const: timer }
        session_id: { type: string, format: uuid }
        status: { type: string }
        timed:
          type: boolean
          description: False when the test has no time limit.
        remaining_seconds: { type: integer }
        expired:
          type: boolean
          description: True once the limit has passed; finalize to get the report.

    CodingRunRequest:
      type: object
      required: [problem_id, language, source_code]
      properties:
        problem_id: { type: string, format: uuid }
        language: { type: string }
        source_code: { type: string }
        stdin:
          type: string
          description: Runs against this input instead of the sample cases.

    CodingRunResult:
      type: object
      properties:
        object: { type: string, const: coding_run }
        verdict: { type: string }
        compile_output: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              is_sample: { type: boolean }
              status: { type: string }
              stdout: { type: string }
              stderr_excerpt: { type: string }
              time_ms: { type: integer }
              memory_kb: { type: integer }
              passed: { type: boolean }

    CodingSubmissionRequest:
      type: object
      required: [problem_id, language, source_code]
      properties:
        problem_id: { type: string, format: uuid }
        language: { type: string }
        source_code: { type: string }

    CodingSubmissionReceipt:
      type: object
      properties:
        object: { type: string, const: coding_submission_receipt }
        submission_id: { type: string, format: uuid }
        attempt_number: { type: integer }
        remaining:
          type: integer
          description: Attempts left for this problem after this one.

    CodingSubmission:
      type: object
      properties:
        object: { type: string, const: coding_submission }
        id: { type: string, format: uuid }
        problem_id: { type: string, format: uuid }
        attempt_number: { type: integer }
        language: { type: string }
        source_code: { type: string }
        status: { type: string, enum: [queued, running, judged, error] }
        verdict:
          type: string
          enum: [PENDING, AC, WA, TLE, MLE, RE, CE, PARTIAL]
        tests_passed: { type: integer }
        tests_total: { type: integer }
        score: { type: number }
        max_runtime_ms: { type: integer }
        max_memory_kb: { type: integer }
        compile_output: { type: string }
        cases:
          type: array
          description: |
            Per-case results. A hidden case carries index, is_sample, passed and
            status only; stdout, timings and expected output are never returned
            for one.
          items:
            type: object
            properties:
              index: { type: integer }
              is_sample: { type: boolean }
              passed: { type: boolean }
              status: { type: string }
              stdout: { type: string, description: Sample cases only. }
              stderr_excerpt: { type: string, description: Sample cases only. }
              time_ms: { type: integer, description: Sample cases only. }
              memory_kb: { type: integer, description: Sample cases only. }
        created_at: { type: integer }
        judged_at: { type: integer }

    CodingFinalization:
      type: object
      properties:
        object: { type: string, const: coding_finalization }
        session_id: { type: string, format: uuid }
        status: { type: string }
        already_completed: { type: boolean }
        problems_scoring:
          type: integer
          description: Problems whose scoring was started by this call.
        report_available: { type: boolean }

    CodingReport:
      type: object
      properties:
        object: { type: string, const: coding_report }
        session_id: { type: string, format: uuid }
        candidate_id: { type: string, format: uuid }
        test_id: { type: string, format: uuid }
        test_title: { type: string }
        status: { type: string }
        overall_score: { type: number }
        readiness_band: { type: string }
        sub_scores:
          type: object
          description: Correctness, efficiency, code quality, complexity, problem solving.
        capability_profile: { type: object }
        strengths: { type: array, items: { type: string } }
        improvements: { type: array, items: { type: string } }
        completed_at: { type: integer }
        problems:
          type: array
          items:
            type: object
            properties:
              title: { type: string }
              difficulty: { type: string }
              attempts_used: { type: integer }
              final_score: { type: number }
              correctness_score: { type: number }
              efficiency_score: { type: number }
              code_quality_score: { type: number }
              complexity_score: { type: number }
              inferred_time_complexity: { type: string }
              inferred_space_complexity: { type: string }
              strengths: { type: array, items: { type: string } }
              improvements: { type: array, items: { type: string } }
              evaluator_summary: { type: string }
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }

    FowAssessment:
      type: object
      properties:
        object: { type: string, const: fow_assessment }
        id: { type: string, format: uuid }
        title: { type: string }
        slug: { type: string }
        description: { type: string }
        mode: { type: string, enum: [tech, non_tech] }
        difficulty: { type: string }
        skill_slug: { type: string }
        skill_name: { type: string }
        max_attempts_per_task: { type: integer }
        time_limit_minutes:
          type: integer
          description: Absent on an untimed assessment.
        created_at: { type: integer }

    FowTask:
      type: object
      description: |
        The candidate-facing view of a task. Scoring rubrics, evaluator prompts
        and reference answers are never included.
      properties:
        object: { type: string, const: fow_task }
        id: { type: string, format: uuid }
        title: { type: string }
        description: { type: string }
        task_input: { type: string }
        task_type: { type: string }
        mode: { type: string }
        difficulty: { type: string }
        expected_output_type: { type: string }
        max_attempts: { type: integer }
        slot:
          type: integer
          description: Variant group. One task per slot is served per session.
        display_order: { type: integer }

    FowAssessmentDetail:
      allOf:
        - $ref: "#/components/schemas/FowAssessment"
        - type: object
          properties:
            tasks:
              type: array
              items: { $ref: "#/components/schemas/FowTask" }

    CreateFowSessionRequest:
      type: object
      required: [assessment_id]
      properties:
        candidate_id: { type: string, format: uuid }
        candidate:
          type: object
          description: Create the candidate inline instead of passing candidate_id.
          required: [email, name]
          properties:
            email: { type: string, format: email }
            name: { type: string }
        assessment_id: { type: string, format: uuid }

    FowSession:
      type: object
      properties:
        object: { type: string, const: fow_session }
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [assigned, started, in_progress, completed, timed_out, cancelled]
        candidate_id: { type: string, format: uuid }
        assessment_id: { type: string, format: uuid }
        assessment_name: { type: string }
        skill_name: { type: string }
        started_at:
          type: integer
          description: Absent until the candidate opens the session.
        completed_at: { type: integer }
        overall_score: { type: number }
        readiness_band:
          type: string
          enum: [beginner, emerging, job_ready, advanced]
        report_available: { type: boolean }
        resumed:
          type: boolean
          description: True when an assign returned a session that already existed.
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }
        created_at: { type: integer }

    FowTaskResult:
      type: object
      description: |
        One scored task: the evaluator's judgement. The candidate's prompts and
        the model output they produced are never part of it.
      properties:
        task_id:
          type: string
          format: uuid
          description: Present on a session, absent on a report, which names the task instead.
        title:
          type: string
          description: Present on a report.
        attempts_used: { type: integer }
        final_score: { type: number }
        prompt_quality_score: { type: number }
        context_framing_score: { type: number }
        instruction_quality_score: { type: number }
        iteration_strategy_score: { type: number }
        efficiency_score: { type: number }
        output_quality_score: { type: number }
        adaptability_score: { type: number }
        professional_usage_score: { type: number }
        strengths: { type: array, items: { type: string } }
        improvements: { type: array, items: { type: string } }
        evaluator_summary: { type: string }

    FowSessionDetail:
      allOf:
        - $ref: "#/components/schemas/FowSession"
        - type: object
          properties:
            tasks:
              type: array
              items: { $ref: "#/components/schemas/FowTask" }
            results:
              type: array
              items: { $ref: "#/components/schemas/FowTaskResult" }
            progress:
              type: array
              items:
                type: object
                properties:
                  task_id: { type: string, format: uuid }
                  attempts_used: { type: integer }
                  max_attempts: { type: integer }
                  has_result: { type: boolean }

    FowTimer:
      type: object
      properties:
        object: { type: string, const: timer }
        session_id: { type: string, format: uuid }
        status: { type: string }
        timed:
          type: boolean
          description: False when the assessment has no time limit.
        remaining_seconds: { type: integer }
        expired:
          type: boolean
          description: True once the limit has passed; finalize to get the report.

    FowAttemptRequest:
      type: object
      required: [task_id, prompt_text]
      properties:
        task_id: { type: string, format: uuid }
        prompt_text: { type: string, maxLength: 10000 }
        input_method:
          type: string
          enum: [typed, voice, paste]
          default: typed

    FowAttemptReceipt:
      type: object
      properties:
        object: { type: string, const: fow_attempt_receipt }
        attempt_id: { type: string, format: uuid }
        attempt_number: { type: integer }
        remaining:
          type: integer
          description: Attempts left on that task after this one.

    FowAttempt:
      type: object
      properties:
        object: { type: string, const: fow_attempt }
        id: { type: string, format: uuid }
        task_id: { type: string, format: uuid }
        attempt_number: { type: integer }
        prompt_text: { type: string }
        generated_output:
          type: string
          description: Absent until generation finishes.
        input_method: { type: string, enum: [typed, voice, paste] }
        status:
          type: string
          enum: [submitted, generating, evaluating, scored, error]
        score: { type: object }
        evaluation_feedback: { type: string }
        created_at: { type: integer }

    FowFinalization:
      type: object
      properties:
        object: { type: string, const: fow_finalization }
        session_id: { type: string, format: uuid }
        status: { type: string }
        already_completed: { type: boolean }
        tasks_scoring:
          type: integer
          description: Tasks whose scoring was started by this call.
        report_available: { type: boolean }

    FowReport:
      type: object
      properties:
        object: { type: string, const: fow_report }
        session_id: { type: string, format: uuid }
        candidate_id: { type: string, format: uuid }
        assessment_id: { type: string, format: uuid }
        assessment_name: { type: string }
        skill_name: { type: string }
        status: { type: string }
        overall_score: { type: number }
        readiness_band: { type: string }
        sub_scores:
          type: object
          description: Prompt quality, context framing, instruction quality, iteration
            strategy, efficiency, output quality, adaptability, professional usage.
        strengths: { type: array, items: { type: string } }
        improvements: { type: array, items: { type: string } }
        completed_at: { type: integer }
        tasks:
          type: array
          items: { $ref: "#/components/schemas/FowTaskResult" }
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }

    CareerDNATest:
      type: object
      properties:
        object: { type: string, const: career_dna_test }
        id: { type: string, format: uuid }
        title: { type: string }
        slug: { type: string }
        description: { type: string }
        icon: { type: string }
        estimated_minutes: { type: integer }
        created_at: { type: integer, format: int64 }

    CareerDNAQuestion:
      type: object
      description: One item. The dimension it measures, its weight, whether it is
        reverse-scored and what each option contributes are the scoring key and
        are never served.
      properties:
        id: { type: string, format: uuid }
        text: { type: string }
        question_type: { type: string, enum: [likert, choice] }
        section: { type: string }
        display_order: { type: integer }
        options:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
        story_order: { type: integer }
        chapter_id: { type: string, format: uuid }
        speaker_key: { type: string }
        npc_line: { type: string }
        replies:
          type: array
          items: { type: object }
          description: Worded answers in order, for the conversational presentation.

    CareerDNAChapter:
      type: object
      description: Presentation only; no effect on scoring.
      properties:
        object: { type: string, const: career_dna_chapter }
        id: { type: string, format: uuid }
        ordinal: { type: integer }
        title: { type: string }
        background_url: { type: string, format: uri }
        background_key: { type: string }
        scene: { type: object }

    CreateCareerDNASessionRequest:
      type: object
      required: [test_id]
      properties:
        test_id: { type: string, format: uuid }
        candidate_id:
          type: string
          format: uuid
          description: An existing candidate. Give this or `candidate`, not both.
        candidate:
          type: object
          required: [email, name]
          properties:
            email: { type: string, format: email }
            name: { type: string, maxLength: 255 }

    CareerDNASession:
      type: object
      properties:
        object: { type: string, const: career_dna_session }
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [assigned, started, in_progress, scoring, completed, timed_out, cancelled]
        candidate_id: { type: string, format: uuid }
        test_id: { type: string, format: uuid }
        test_title: { type: string }
        started_at:
          type: integer
          format: int64
          description: Absent until the candidate starts the session.
        completed_at: { type: integer, format: int64 }
        report_available:
          type: boolean
          description: True from the submit. The scores do not wait for the written
            profile.
        narrative_ready:
          type: boolean
          description: Whether the written profile has been generated.
        resumed: { type: boolean }
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }
        created_at: { type: integer, format: int64 }

    CareerDNASessionDetail:
      allOf:
        - $ref: "#/components/schemas/CareerDNASession"
        - type: object
          properties:
            test: { $ref: "#/components/schemas/CareerDNATest" }
            questions:
              type: array
              items: { $ref: "#/components/schemas/CareerDNAQuestion" }
            chapters:
              type: array
              items: { $ref: "#/components/schemas/CareerDNAChapter" }
            answers:
              type: object
              description: What has been saved so far, keyed by question id.

    CareerDNAAnswers:
      type: object
      required: [answers]
      properties:
        answers:
          type: object
          description: Keyed by question id. An integer 1-5 for a likert item, an
            option key for a choice item.
          additionalProperties: true

    CareerDNAProgress:
      type: object
      properties:
        object: { type: string, const: career_dna_progress }
        session_id: { type: string, format: uuid }
        ok: { type: boolean }
        ignored:
          type: boolean
          description: True when the session was already submitted and nothing was
            written.

    CareerDNASubmission:
      type: object
      properties:
        object: { type: string, const: career_dna_submission }
        session_id: { type: string, format: uuid }
        status: { type: string, const: scoring }
        report_available: { type: boolean }
        narrative_ready: { type: boolean }

    CareerDNATrait:
      type: object
      description: One bipolar dimension. An unmeasured dimension reports a null
        position rather than a midpoint.
      properties:
        key: { type: string }
        label: { type: string }
        low_label: { type: string }
        high_label: { type: string }
        position: { type: [number, "null"] }
        band: { type: string }
        band_label: { type: string }
        blurb: { type: string }

    CareerDNARoleFit:
      type: object
      description: Fit against a calibration anchor role, absolute rather than a
        rank rescale. Five at most.
      properties:
        key: { type: string }
        label: { type: string }
        score: { type: number }
        rank: { type: integer }
        tier: { type: string }
        tier_label: { type: string }

    CareerDNAReport:
      type: object
      description: |
        The same allowlist the candidate's own share link carries, plus the two
        disclaimers. Not a hiring or shortlisting signal.
      properties:
        object: { type: string, const: career_dna_report }
        session_id: { type: string, format: uuid }
        candidate_id: { type: string, format: uuid }
        test_id: { type: string, format: uuid }
        test_title: { type: string }
        status: { type: string }
        archetype: { type: [string, "null"] }
        profile_type: { type: [string, "null"] }
        holland_code: { type: [string, "null"] }
        scoring_version: { type: integer }
        narrative_version:
          type: integer
          description: Zero or negative while the written profile is queued or was
            given up on.
        narrative_ready: { type: boolean }
        headline: { type: string }
        summary:
          type: string
          description: A placeholder until narrative_ready is true.
        traits:
          type: array
          items: { $ref: "#/components/schemas/CareerDNATrait" }
        interests:
          type: array
          items: { $ref: "#/components/schemas/CareerDNATrait" }
        value_axes:
          type: array
          items: { $ref: "#/components/schemas/CareerDNATrait" }
        role_fits:
          type: array
          items: { $ref: "#/components/schemas/CareerDNARoleFit" }
        directions: { type: array, items: { type: object } }
        explore_roles: { type: array, items: { type: object } }
        dimension_scores: { type: object }
        company_fit: { type: object }
        strengths: { type: array, items: { type: string } }
        work_style: { type: array, items: { type: object } }
        environment_fit: { type: string }
        role_suggestions: { type: array, items: { type: object } }
        company_types: { type: array, items: { type: string } }
        completed_at: { type: integer, format: int64 }
        created_at: { type: integer, format: int64 }
        disclaimer:
          type: string
          description: Display this with the report.
        roles_disclaimer:
          type: string
          description: Display this with the role fits.
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }

    SubjectKind:
      type: string
      description: What a session or launch token is bound to. The id spaces do not
        overlap, so always read the kind alongside the id.
      enum: [interview, coding, fow, career_dna]

    LaunchLink:
      type: object
      properties:
        object: { type: string, const: launch_link }
        subject_kind: { $ref: "#/components/schemas/SubjectKind" }
        subject_id: { type: string, format: uuid }
        interview_id:
          type: string
          format: uuid
          description: Present only when subject_kind is interview. Prefer subject_id.
        url: { type: string, format: uri }
        token: { type: string }
        expires_at:
          type: integer
          description: >-
            The earlier of the requested TTL and the end of the assignment
            window, so a link minted late in that window is short-lived.

    Realtime:
      type: object
      properties:
        provider: { type: string, const: agora }
        app_id: { type: string }
        channel:
          type: string
          description: Opaque. Do not construct, parse or persist it.
        rtc_token: { type: string }
        rtm_token: { type: string }
        uid: { type: integer }
        agent_uid:
          type: integer
          description: The AI interviewer's uid. Subscribe to it to hear the interviewer.
        expires_at: { type: integer }

    JoinGrant:
      type: object
      properties:
        object: { type: string, const: join_grant }
        interview_id: { type: string, format: uuid }
        realtime: { $ref: "#/components/schemas/Realtime" }
        candidate_name: { type: string }
        role: { type: string }
        round_type: { type: string }
        duration_seconds: { type: integer }
        supports_code_editor: { type: boolean }
        supports_whiteboard: { type: boolean }
        assessment_type: { type: string }

    AssessmentEventsRequest:
      type: object
      description: |
        A batch of proctoring signals for a coding or Prompt Engineering session.
        The interview endpoint takes `event` and no meta; that shape is frozen.
      required: [events]
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: object
            required: [type, timestamp]
            properties:
              type: { type: string, maxLength: 64 }
              timestamp:
                type: integer
                format: int64
                description: Unix seconds. A non-positive value is stored as the
                  time the batch arrived.
              meta:
                type: object
                additionalProperties: true
                description: Free-form context for this signal, stored with it.
                  Optional.

    SessionContext:
      type: object
      properties:
        object: { type: string, const: session_context }
        subject_kind: { $ref: "#/components/schemas/SubjectKind" }
        subject_id: { type: string, format: uuid }
        interview_id:
          type: string
          format: uuid
          description: Present only when subject_kind is interview. Prefer subject_id.
        status: { type: string }
        round_type:
          type: string
          description: The interview round type, or the assessment kind (coding,
            fow, career_dna).
        role:
          type: string
          description: Present only when subject_kind is interview.
        title:
          type: string
          description: What the room names this subject - the job role for an
            interview, the test or assessment title otherwise.
        candidate_name: { type: string }
        organisation_name: { type: string }
        logo_url: { type: string, format: uri }
        scheduled_at: { type: integer, format: int64 }
        timed:
          type: boolean
          description: False for a subject with no deadline. Psychometric Test is untimed,
            and so is a coding test or Prompt Engineering assessment with no time limit.
        duration_seconds:
          type: integer
          format: int64
          description: Absent when timed is false.
        capabilities:
          type: object
          description: What the room may offer for this subject.
          properties:
            proctoring:
              type: boolean
              description: False for Psychometric Test - a self-report questionnaire with
                no right answers has nothing to proctor.
            fullscreen:
              type: string
              enum: [enforced, advisory, off]
              description: Always advisory here. Enforcement depends on whether the
                room is embedded, which the server cannot see.
            run:
              type: boolean
              description: True for coding only. Without it the room hides Run and
                every trial costs an attempt.
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }

    Resume:
      type: object
      description: Identical in shape to the resume.scored webhook payload.
      properties:
        object: { type: string, const: resume }
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        role: { type: string }
        overall_score: { type: integer }
        ats_readiness: { type: integer }
        domain_skill_fit: { type: integer }
        experience_relevance: { type: integer }
        resume_readability: { type: integer }
        missing_keywords: { type: string }
        missing_links: { type: string }
        strengths: { type: string }
        weakness: { type: string }
        improvements: { type: string }
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }

    Domain:
      type: object
      properties:
        object: { type: string, const: domain }
        id: { type: string, format: uuid }
        origin: { type: string }
        environment: { type: string, enum: [live, sandbox] }
        created_by: { type: string, description: "How it was added: api_key or platform_admin." }
        created_by_key_id: { type: string, format: uuid }
        created_at: { type: integer, format: int64 }

    DomainList:
      type: object
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/Domain" }
        has_more: { type: boolean }
        total_count: { type: integer }

    EmbedConfig:
      type: object
      properties:
        object: { type: string, const: embed_config }
        allowed_origins:
          type: array
          items: { type: string }
        organisation_name: { type: string }
        logo_url: { type: string, format: uri }
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }

    Timer:
      type: object
      properties:
        object: { type: string, const: timer }
        interview_id: { type: string, format: uuid }
        remaining_seconds: { type: integer, format: int64 }
        status: { type: string }

    Media:
      type: object
      properties:
        recording_url: { type: string, format: uri }
        transcript_url: { type: string, format: uri }
        face_image_url: { type: string, format: uri }
        has_whiteboard: { type: boolean }

    InterviewReport:
      type: object
      properties:
        object: { type: string, const: interview_report }
        interview_id: { type: string, format: uuid }
        status: { type: string }
        round_type: { type: string }
        difficulty: { type: integer }
        overall_score: { type: integer }
        summary: { type: string }
        feedback: { $ref: "#/components/schemas/InterviewFeedback" }
        main_question: { type: string }
        code: { type: string }
        code_analysis: {}
        media: { $ref: "#/components/schemas/Media" }
        events:
          type: array
          items: { $ref: "#/components/schemas/ProctoringEventRecord" }
        started_at: { type: integer }
        ended_at: { type: integer }
        duration_seconds: { type: integer }
        generated_at: { type: integer }

    InterviewFeedback:
      type: object
      description: |
        Written by the scoring pipeline, so the keys are the same on every round
        type and only the contents differ. Fields the pipeline does not produce
        for a round are still present, holding zero values: `code_score` is all
        zeros on a non-coding round, and `audio_analysis`, `hiring_signal`,
        `anchor_insight` and `impact_indicators` are always empty here.

        Treat it as additive. New keys appear without a version bump.
      properties:
        overall_score: { type: integer, minimum: 0, maximum: 100 }
        field_knowledge:
          type: array
          description: |
            Subject-matter judgement. The criteria are generated per transcript
            rather than drawn from a fixed list, so the names differ between
            candidates. Do not key a rubric off them.
          items:
            type: object
            properties:
              criteria: { type: string }
              score: { type: integer, minimum: 0, maximum: 100 }
              comments: { type: string }
        speech_analysis:
          type: array
          description: |
            Five fixed criteria, judged from the transcript rather than measured
            from audio: Fluency, Pronunciation, Grammar, Active Listening,
            Confidence. Note the scale key here is `rating`, not `score`.
          items:
            type: object
            properties:
              criteria: { type: string }
              rating: { type: integer, minimum: 1, maximum: 100 }
              comments: { type: string }
        behavioural_analysis:
          type: array
          description: |
            Eye Contact, Posture and Emotional State, derived from video. An
            audio-only interview returns all three at score 0 with outcome
            "Not Analyzed (Audio Only)", which is a sentinel and not a result.
          items:
            type: object
            properties:
              label: { type: string }
              score: { type: integer, minimum: 0, maximum: 100 }
              outcome: { type: string }
        code_score:
          type: object
          description: Zeros unless the round ran code.
          additionalProperties:
            type: object
            properties:
              score: { type: integer }
              reason: { type: string }
        num_speakers: { type: integer }
        num_faces_detected: { type: integer }
        off_platform_time: { type: integer }
        voice_verification:
          type: object
          description: |
            Integrity signal, not grading. Carries impostor and spoof verdicts.
            Allowlist the keys you forward if you re-render this to a candidate.
      additionalProperties: true

    ProctoringEventRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        interview_id: { type: string, format: uuid }
        event:
          type: string
          description: |
            One of tab_switch, tab_switch_end, multiple_faces_detected,
            multiple_voices_detected, face_not_in_frame, looked_away,
            dev_console_opened, code_submitted, code_run, board_submitted,
            multiple_monitors, paste, copy, cut. In practice the hosted room
            emits only tab_switch, tab_switch_end, multiple_monitors and
            code_submitted.
        timestamp: { type: integer }

    RoundTypeInfo:
      type: object
      properties:
        object: { type: string, const: round_type }
        slug: { type: string }
        name: { type: string }
        description: { type: string }
        supports_code_editor:
          type: boolean
          description: Render a code editor and POST to /v1/interviews/{id}/code.
        supports_whiteboard:
          type: boolean
          description: Render a canvas and upload via /v1/interviews/{id}/board-upload-url.
        meeting_minutes: { type: integer }

    CompanyInfo:
      type: object
      properties:
        object: { type: string, const: company }
        id: { type: string, format: uuid }
        name: { type: string }
        domain: { type: string }

    JobInfo:
      type: object
      properties:
        object: { type: string, const: job }
        id: { type: string, format: uuid }
        title: { type: string }
        category: { type: string }

    CodingLanguage:
      type: object
      properties:
        object: { type: string, const: coding_language }
        slug: { type: string }

    Event:
      type: object
      properties:
        object: { type: string, const: event }
        id: { type: string, format: uuid }
        event_id:
          type: string
          description: Shared across every endpoint this event fanned out to, so deliveries correlate as one event.
        type: { type: string }
        endpoint_id: { type: string, format: uuid }
        status: { type: string, enum: [pending, processing, delivered, failed, retrying] }
        attempt: { type: integer }
        max_attempts: { type: integer }
        error: { type: string }
        payload:
          type: string
          description: The exact body that was signed and sent.
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }
        created_at: { type: integer, format: int64 }

    WebhookDelivery:
      type: object
      description: One attempt. Request headers are never included; they carry the signature.
      properties:
        object: { type: string, const: webhook_delivery }
        id: { type: string, format: uuid }
        event_id: { type: string }
        attempt: { type: integer }
        url: { type: string, format: uri }
        response_status_code: { type: integer }
        response_body: { type: string, description: Truncated. }
        duration_ms: { type: integer }
        status: { type: string }
        error: { type: string }
        created_at: { type: integer, format: int64 }

    WebhookEndpoint:
      type: object
      properties:
        object: { type: string, const: webhook_endpoint }
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        description: { type: string }
        status: { type: string }
        environment: { type: string, enum: [live, sandbox] }
        enabled_events:
          type: array
          items: { type: string }
        secret:
          type: string
          description: Returned on creation and rotation only.
        created_at: { type: integer }

    CreditUsage:
      type: object
      properties:
        remaining: { type: integer }
        granted: { type: integer }
        used_this_period: { type: integer }

    Usage:
      type: object
      properties:
        object: { type: string, const: usage }
        environment: { type: string, enum: [live, sandbox] }
        livemode: { type: boolean }
        credits:
          type: object
          additionalProperties: { $ref: "#/components/schemas/CreditUsage" }
        modules:
          type: object
          additionalProperties: { type: boolean }
        concurrency:
          type: object
          additionalProperties: { type: integer }
