openapi: 3.1.0
info:
  title: ZenHire API
  version: "1.0.0"
  description: |
    This reference covers the ZenHire platform APIs, grouped into modules.
    All endpoints authenticate with an `X-API-Key` header.

    **CV DeepMatch** (`platform.zenhire.ai/api/v1/cvdeepmatch`) — asynchronous
    CV ↔ job-description match scoring. Submit a CV (PDF) plus a job
    description and a requirements config; poll for a structured score
    breakdown.

    **Interview** (`platform.zenhire.ai/api/v1/interview`) — AI voice
    interviews. Create a reusable interviewer persona, start a per-candidate
    session that mints a candidate link, then retrieve the recording (WAV) and
    transcript (JSON) in-platform once the interview ends.

    **CV DeepSearch** (`platform.zenhire.ai/api/v1/cvds`) — search a corpus of
    parsed CVs for the best-matching candidates against a position. Ingest a
    corpus once, then run repeated searches; poll the run or receive a webhook
    for the best-first results.

    **Speech** (`platform.zenhire.ai/api/v1/speech`) — asynchronous
    speech-proficiency analysis. Upload an audio recording of an interview and
    receive vocabulary, fluency, and accent scores aligned with the CEFR
    framework. Submit returns an `id`; poll for results.

    **Universal** (`platform.zenhire.ai/api/v1`) — cross-module endpoints
    shared by every module: credit balance (`/api/v1/credits`) and health
    (`/api/v1/health`).

    Pick a module from the sidebar to see its endpoints and schemas.
  contact:
    name: Book a demo
    url: https://zenhire.ai/request-demo?source=api-docs

servers:
  - url: https://platform.zenhire.ai
    description: Production

# BKL-073 (EPIC-005): tags mirror the unified 4-group API surface —
# CV DeepMatch / Interview / Speech / Universal. This spec covers the Speech
# module plus the two Universal (cross-module) endpoints; CV DeepMatch and
# Interview live in their own specs (openapi-cvdeepmatch.yaml / openapi-interview.yaml).
# `credits` + `health` are Universal (cross-module), NOT Speech-specific.
tags:
  - name: Speech — Analysis
    description: Submit and retrieve speech proficiency analyses.
  - name: Speech — Runs
    description: Query historical Speech runs.
  - name: Universal — Credits
    description: Check credit balance and usage (shared across all modules).
  - name: Universal — Health
    description: Service health status (shared across all modules).

security:
  - ApiKeyAuth: []

paths:
  /api/v1/speech/analyze:
    post:
      tags: [Speech — Analysis]
      summary: Submit speech analysis
      operationId: submitSpeechAnalysis
      description: |
        Submits an audio file for asynchronous speech proficiency analysis.
        Returns immediately (202 Accepted) with an `id`. Poll
        `GET /api/v1/speech/analyze/{id}` for results.

        ### Audio requirements
        - **Accepted formats**: MP3, WAV, M4A, WebM, OGG, FLAC
        - **Minimum duration**: 3 minutes
        - **Maximum duration**: 45 minutes
        - **Maximum file size**: 25 MB

        ### Rate limits
        - Default: 500 requests/minute per client (configurable per key)

        ### Concurrency
        Default **8 simultaneous `processing` runs per client** (contact
        support to extend). When at capacity, the request is **queued**
        (not rejected) and starts automatically when a slot frees up,
        in FIFO order.
      parameters:
        - name: includeCutAudio
          in: query
          required: false
          description: If `true`, the poll response includes the base64-encoded candidate-only cut audio.
          schema:
            type: boolean
            default: false
        - name: analysis
          in: query
          required: false
          description: If `false`, skips writing detailed analysis metrics to the analysis database (reduces cost for high-volume, non-analytic use cases).
          schema:
            type: boolean
            default: true
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [audio]
              properties:
                audio:
                  type: string
                  format: binary
                  description: The audio file to analyze.
                language:
                  type: string
                  pattern: "^[a-z]{2}$"
                  default: "en"
                  description: |
                    ISO 639-1 2-letter language code. Non-English values
                    route the request through the multilingual pipeline
                    and require the multilingual permission on your
                    account (otherwise returns `403 MULTILINGUAL_NOT_ENABLED`).
                  example: en
                externalId:
                  type: string
                  maxLength: 255
                  description: |
                    Optional customer-supplied correlation id (1–255 chars).
                    Stored on the run, echoed back on submit and in every
                    poll response, and filterable on `GET /api/v1/speech/runs`.
                    **Not unique** — you may reuse the same externalId
                    across multiple runs (e.g. re-analysis of the same
                    candidate).
                  example: candidate-abc-123
                metadata:
                  type: string
                  description: |
                    Optional JSON object (stringified) attached to the run for
                    your own reference. A simple `{key:value}` map (string keys,
                    string/number/boolean values) is also stored as structured,
                    filterable run metadata and echoed back on every poll
                    response (≤ 50 keys, key ≤ 40 chars, value ≤ 500 chars,
                    total ≤ 8 KB). Free-form/nested objects are still accepted
                    and stored as-is for your reference, but only the structured
                    subset is filterable.
                  example: '{"candidate_id":"c_887","role":"backend"}'
                tags:
                  type: string
                  description: |
                    Optional JSON array of strings (stringified) attached to the
                    run for your own correlation/filtering. Up to 20 distinct
                    tags, each 1–40 chars (trimmed; duplicates removed). Echoed
                    back on every poll response. Invalid tags return
                    `400 INVALID_TAGS`.
                  example: '["batch-3","eu-region"]'
                cutSettings:
                  type: string
                  description: Optional JSON object (stringified) overriding the default silence-cut settings.
      responses:
        "202":
          description: Accepted. Either queued or started processing.
          content:
            application/json:
              examples:
                processing:
                  summary: Slot available — started processing immediately
                  value:
                    id: req_1705412345678_abc123
                    externalId: candidate-abc-123
                    status: processing
                    message: "Audio accepted for processing. Poll GET /api/v1/speech/analyze/{id} for results."
                    estimatedTimeSeconds: 120
                    pollIntervalSeconds: 15
                    timestamp: "2026-04-20T10:05:13.201Z"
                queued:
                  summary: At capacity — queued
                  value:
                    id: req_1705412345678_abc123
                    externalId: candidate-abc-123
                    status: queued
                    message: "Audio accepted and queued for processing. Your request will begin automatically when a processing slot becomes available."
                    queuePosition: 3
                    activeRequests: 8
                    estimatedTimeSeconds: 210
                    pollIntervalSeconds: 20
                    timestamp: "2026-04-20T10:05:13.201Z"
              schema:
                $ref: "#/components/schemas/SubmitResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: >-
            Insufficient credits. The request is rejected when its cost
            (1 credit per started minute of audio, minimum 1) exceeds your
            available balance. Available balance is your credit balance minus
            credits currently held for other in-flight requests. No charge is
            made and no request is created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  code: INSUFFICIENT_CREDITS
                  message: "Insufficient credits. This 5-credit request exceeds your available balance of 2 credits. Please purchase more credits to continue using the API."
                  requestId: req_1705412345678_abc123
                  details:
                    cost: 5
                    available: 2
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Scoring service still warming up after a deploy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requestId: { type: string }
                  error: { type: string, example: SERVICE_WARMING_UP }
                  message: { type: string }
                  retryAfterSeconds: { type: integer, example: 30 }

  /api/v1/speech/analyze/{requestId}:
    get:
      tags: [Speech — Analysis]
      summary: Poll analysis status
      operationId: pollSpeechAnalysis
      description: |
        Returns the current status of a previously submitted analysis.
        Safe to poll repeatedly. The minimum enforced interval is 10
        seconds for non-terminal statuses; faster polls return `429`.

        Response shape varies by `status`:

        - `queued` / `processing`: lightweight status payload, `Retry-After` header set
        - `success` / `partial`: full result payload with scores, analysis, transcript
        - `failed`: terminal with `error.code` and `error.message`

        The `id` does not expire — you can stop and resume polling
        later. (The path parameter accepts the same value returned as `id`
        on submit.)
      parameters:
        - name: requestId
          in: path
          required: true
          description: The analysis `id` returned on submit.
          schema:
            type: string
          example: req_1705412345678_abc123
      responses:
        "200":
          description: Current state of the run.
          content:
            application/json:
              examples:
                queued:
                  summary: Queued
                  value:
                    id: req_1705412345678_abc123
                    externalId: candidate-abc-123
                    status: queued
                    message: "Your request is queued and will begin processing automatically when a slot becomes available."
                    queuePosition: 3
                    activeRequests: 8
                    pollIntervalSeconds: 20
                    createdAt: "2026-04-20T10:05:13.201Z"
                    timestamp: "2026-04-20T10:06:00.000Z"
                processing:
                  summary: Processing
                  value:
                    id: req_1705412345678_abc123
                    externalId: candidate-abc-123
                    status: processing
                    message: "Analysis is still in progress. Please continue polling."
                    pollIntervalSeconds: 15
                    createdAt: "2026-04-20T10:05:13.201Z"
                    timestamp: "2026-04-20T10:06:30.000Z"
                success:
                  summary: Success
                  value:
                    id: req_1705412345678_abc123
                    externalId: candidate-abc-123
                    timestamp: "2026-04-20T10:06:15.015Z"
                    processingTimeMs: 62473
                    status: success
                    scoringMethod: ml_hybrid
                    audio:
                      durationMs: 180000
                      durationFormatted: "3:00"
                      languageCode: eng
                      requestedLanguage: en
                      speakerCount: 2
                    candidateDetection:
                      speaker: speaker_1
                      speakerLabel: "Speaker 2"
                      confidence: high
                      reason: "Speaker answers questions and describes experience"
                      wordCount: 432
                    scores:
                      vocabulary: 4.37
                      vocabularyCefr: C2
                      vocabularyPercent: 87
                      fluency: 4.63
                      fluencyCefr: C2
                      fluencyPercent: 93
                      accent: 4.25
                      accentCefr: C2
                      accentPercent: 85
                      overall: 4.42
                      cefrLevel: C2
                      overallPercent: 88
                    explainability:
                      vocab: "Advanced vocabulary with consistent use of C1+ words (23% of unique types) and strong commonality depth. Lexical diversity is high (MTLD 87). A few repeated filler constructions slightly cap the ceiling."
                      fluency: "Fluid delivery at 156 words/min with a strong phonation ratio (71%). Short pauses dominate and sustained bursts reach 14s. Minor hesitation markers early in the response."
                    hrRecommendation: "Strong communicator suitable for client-facing roles."
                    candidateFeedback: "Your communication skills are strong. Focus on reducing fillers."
                    credits:
                      deducted: 3
                      remainingBalance: 538
                      audioDurationMinutes: 3
                failed:
                  summary: Failed
                  value:
                    id: req_1705412345678_abc123
                    externalId: candidate-abc-123
                    status: failed
                    timestamp: "2026-04-20T10:06:15.015Z"
                    processingTimeMs: 42100
                    error:
                      code: TRANSCRIPTION_FAILED
                      message: "Transcription failed: audio appears corrupted."
              schema:
                $ref: "#/components/schemas/PollResponse"
        "404":
          description: Request id not found (or belongs to another client).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Polled faster than the minimum 10s interval.
          headers:
            Retry-After:
              schema: { type: integer }
          content:
            application/json:
              schema:
                type: object
                properties:
                  requestId: { type: string }
                  error: { type: string, example: POLL_RATE_LIMITED }
                  message: { type: string }
                  retryAfterSeconds: { type: integer }

  /api/v1/speech/runs:
    get:
      tags: [Speech — Runs]
      summary: List runs
      operationId: listRuns
      description: |
        Lists your API runs, newest first. Returns a compact summary per run —
        fetch the full result (analysis, transcript, etc.) via
        `GET /api/v1/speech/analyze/{id}`.

        Playground runs are excluded — they are tracked separately and are
        not part of the API runs collection.
      parameters:
        - name: limit
          in: query
          required: false
          description: Page size (max 100).
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: offset
          in: query
          required: false
          description: Number of rows to skip.
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: status
          in: query
          required: false
          description: Filter by lifecycle status.
          schema:
            type: string
            enum: [queued, processing, success, partial, failed]
        - name: serviceType
          in: query
          required: false
          description: Filter by service.
          schema:
            type: string
            enum: [speech, cv_deepmatch]
        - name: project
          in: query
          required: false
          description: |
            Scope to one project. Pass a project id for an exact match, or the
            literal `none` to return only runs that belong to no project. The
            same `project` filter works identically across every module's runs
            list — learn it once.
          schema: { type: string }
          example: prj_a1b2c3
        - name: tags
          in: query
          required: false
          description: |
            Filter by run tags with contains-ALL semantics — a run matches only
            if it carries every tag you list. Repeat the param (`?tags=a&tags=b`)
            or comma-separate (`?tags=a,b`).
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
          example: [team-emea, q2-batch]
        - name: metadata_key
          in: query
          required: false
          description: |
            Filter by one metadata entry (exact key/value match). Supply both
            `metadata_key` and `metadata_value`; a run matches only if its
            metadata contains exactly that pair.
          schema: { type: string }
          example: env
        - name: metadata_value
          in: query
          required: false
          description: The value to match for `metadata_key` (exact match).
          schema: { type: string }
          example: production
        - name: externalId
          in: query
          required: false
          description: Exact match on the customer-supplied externalId.
          schema: { type: string }
          example: candidate-abc-123
        - name: createdAfter
          in: query
          required: false
          description: ISO 8601 datetime, inclusive lower bound on `createdAt`.
          schema: { type: string, format: date-time }
        - name: createdBefore
          in: query
          required: false
          description: ISO 8601 datetime, inclusive upper bound on `createdAt`.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: List of runs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  runs:
                    type: array
                    items: { $ref: "#/components/schemas/RunSummary" }
                  pagination:
                    type: object
                    properties:
                      limit: { type: integer }
                      offset: { type: integer }
                      count: { type: integer }
                      hasMore: { type: boolean }
                  timestamp: { type: string, format: date-time }
              example:
                runs:
                  - id: req_1705412345678_abc123
                    externalId: candidate-abc-123
                    status: success
                    serviceType: speech
                    createdAt: "2026-04-20T10:05:13.201Z"
                    processingTimeMs: 62473
                    audio:
                      durationMs: 180000
                      durationFormatted: "3:00"
                      languageCode: eng
                      requestedLanguage: en
                      speakerCount: 2
                    scores:
                      vocabulary: 4.37
                      vocabularyCefr: C2
                      fluency: 4.63
                      fluencyCefr: C2
                      accent: 4.25
                      accentCefr: C2
                      overall: 4.42
                      cefrLevel: C2
                pagination: { limit: 20, offset: 0, count: 1, hasMore: false }
                timestamp: "2026-04-20T10:10:00.000Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/credits:
    get:
      tags: [Universal — Credits]
      summary: Get credit balance
      operationId: getCredits
      description: Returns current credit balance, aggregate usage, and recent transactions.
      responses:
        "200":
          description: Balance and recent activity.
          content:
            application/json:
              schema:
                type: object
                properties:
                  clientId: { type: string }
                  creditBalance: { type: integer }
                  usage:
                    type: object
                    properties:
                      speechCreditsUsed: { type: integer }
                      cvCreditsUsed: { type: integer }
                      totalCreditsAdded: { type: integer }
                  recentTransactions:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        type: { type: string, enum: [debit, credit] }
                        amount: { type: integer }
                        balanceAfter: { type: integer }
                        serviceType: { type: string }
                        referenceType: { type: string }
                        referenceId: { type: string }
                        audioDurationMinutes: { type: number, nullable: true }
                        description: { type: string }
                        createdAt: { type: string, format: date-time }
                  timestamp: { type: string, format: date-time }
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ===========================================================================
  # CV DeepMatch (EPIC-001 / BKL-008) lives in a separate spec:
  #   openapi/openapi-cvdeepmatch.yaml
  # It generates its own Docusaurus tree under docs/site/docs/cv-deepmatch/,
  # mirroring the Interview API split. See docusaurus.config.ts (`cvdeepmatch`
  # plugin config) and sidebars.ts (manual category insertion).
  # ===========================================================================

  /api/v1/health:
    get:
      tags: [Universal — Health]
      summary: Health check
      operationId: getHealth
      description: |
        Checks API and downstream-service status. **No authentication required.**

        Returns `200` when all checks report `healthy` or `configured`;
        `503` when any check reports `unhealthy`, `unreachable`, or
        `not_configured`.
      security: []
      responses:
        "200":
          description: All systems healthy.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }
        "503":
          description: One or more checks failing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "API key issued in the ZenHire dashboard. Format: `zh_api_…`."

  schemas:
    SubmitResponse:
      type: object
      required: [id, status, message, pollIntervalSeconds, timestamp]
      properties:
        # BKL-079 (EPIC-005): the single canonical run identifier across all
        # modules. (Was `requestId`.) `externalId` stays the distinct
        # customer-supplied correlation tag.
        id: { type: string, example: req_1705412345678_abc123 }
        externalId: { type: string, nullable: true }
        status:
          type: string
          enum: [queued, processing]
        message: { type: string }
        estimatedTimeSeconds: { type: integer }
        pollIntervalSeconds: { type: integer }
        queuePosition: { type: integer, nullable: true }
        activeRequests: { type: integer, nullable: true }
        timestamp: { type: string, format: date-time }

    PollResponse:
      description: Shape varies by `status`. See the endpoint examples.
      type: object
      required: [id, status]
      properties:
        # BKL-079 — single canonical run identifier (was `requestId`).
        id: { type: string }
        externalId: { type: string, nullable: true }
        status:
          type: string
          enum: [queued, processing, success, partial, failed]
        timestamp: { type: string, format: date-time }
        processingTimeMs: { type: integer }
        scoringMethod:
          type: string
          enum: [ml_hybrid, gpt_multilingual]
        audio:
          type: object
          nullable: true
          properties:
            durationMs: { type: integer }
            durationFormatted: { type: string }
            languageCode: { type: string }
            requestedLanguage: { type: string }
            speakerCount: { type: integer }
        candidateDetection:
          type: object
          nullable: true
          properties:
            speaker: { type: string }
            speakerLabel: { type: string }
            confidence: { type: string, enum: [high, medium, low] }
            reason: { type: string }
            wordCount: { type: integer }
        scores:
          $ref: "#/components/schemas/Scores"
        explainability:
          type: object
          nullable: true
          description: |
            Plain-language, ~2-3 sentence summary of *why* each score
            came out the way it did. Safe to show directly to end
            users (hiring managers, candidates, auditors). Only
            present when the heuristic scoring pipeline produced a
            per-dimension feature breakdown — omitted for `failed`
            runs and in rare cases where breakdowns aren't available.
          properties:
            vocab: { type: string }
            fluency: { type: string }
        analysis:
          type: object
          nullable: true
          description: Detailed per-dimension analysis (vocabulary, fluency, accent).
        hrRecommendation: { type: string, nullable: true }
        candidateFeedback: { type: string, nullable: true }
        transcript:
          type: object
          nullable: true
          properties:
            segments: { type: array, items: { type: object } }
            speakers: { type: array, items: { type: object } }
            candidateText: { type: string }
            candidateSpeaker: { type: string }
        credits:
          type: object
          nullable: true
          properties:
            deducted: { type: integer }
            remainingBalance: { type: integer }
            audioDurationMinutes: { type: number }
        error:
          type: object
          nullable: true
          description: Present when `status=failed`.
          properties:
            code: { type: string }
            message: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }
          description: |
            The structured `{key:value}` run metadata you supplied on submit
            (string values). `{}` when none. Returned on every poll status.
          example: { candidate_id: "c_887", role: "backend" }
        tags:
          type: array
          items: { type: string }
          description: |
            The run tags you supplied on submit (de-duped, trimmed). `[]` when
            none. Returned on every poll status.
          example: ["batch-3", "eu-region"]

    RunSummary:
      type: object
      properties:
        # BKL-079 — single canonical run identifier (was `requestId`).
        id: { type: string }
        externalId: { type: string, nullable: true }
        status:
          type: string
          enum: [queued, processing, success, partial, failed]
        serviceType:
          type: string
          enum: [speech, cv_deepmatch]
        createdAt: { type: string, format: date-time }
        processingTimeMs: { type: integer, nullable: true }
        audio:
          type: object
          properties:
            durationMs: { type: integer, nullable: true }
            durationFormatted: { type: string, nullable: true }
            languageCode: { type: string, nullable: true }
            requestedLanguage: { type: string, nullable: true }
            speakerCount: { type: integer, nullable: true }
        scores:
          type: object
          properties:
            vocabulary: { type: number, nullable: true }
            vocabularyCefr: { type: string, nullable: true }
            fluency: { type: number, nullable: true }
            fluencyCefr: { type: string, nullable: true }
            accent: { type: number, nullable: true }
            accentCefr: { type: string, nullable: true }
            overall: { type: number, nullable: true }
            cefrLevel: { type: string, nullable: true }
        error:
          type: object
          nullable: true
          properties:
            code: { type: string }
            message: { type: string }

    Scores:
      type: object
      description: Flat score object. Scores are on a 0–5 scale; percentages are 0–100.
      properties:
        vocabulary: { type: number, nullable: true }
        vocabularyCefr: { type: string, nullable: true }
        vocabularyPercent: { type: integer, nullable: true }
        fluency: { type: number, nullable: true }
        fluencyCefr: { type: string, nullable: true }
        fluencyPercent: { type: integer, nullable: true }
        accent: { type: number, nullable: true }
        accentCefr: { type: string, nullable: true }
        accentPercent: { type: integer, nullable: true }
        overall: { type: number, nullable: true }
        cefrLevel: { type: string, nullable: true }
        overallPercent: { type: integer, nullable: true }

    HealthResponse:
      type: object
      properties:
        status:
          type: string
          enum: [healthy, degraded]
        timestamp: { type: string, format: date-time }
        checks:
          type: object
          additionalProperties:
            type: object
            properties:
              status:
                type: string
                enum: [healthy, unhealthy, unreachable, configured, not_configured]
              latency: { type: integer, nullable: true }

    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            requestId: { type: string, nullable: true }
            step: { type: string, nullable: true }
            details: { type: object, nullable: true }
            timestamp: { type: string, format: date-time }

  responses:
    BadRequest:
      description: |
        Invalid request (missing or malformed parameters).
        `error.code` values include `MISSING_AUDIO_FILE`,
        `UNSUPPORTED_FORMAT`, `FILE_TOO_LARGE`, `INVALID_LANGUAGE_CODE`,
        `INVALID_EXTERNAL_ID`, `INVALID_METADATA`, `AUDIO_TOO_SHORT`,
        `AUDIO_TOO_LONG`, `AUDIO_DURATION_UNAVAILABLE`, `INVALID_STATUS`,
        `INVALID_SERVICE_TYPE`, `INVALID_DATE`, `INVALID_PARAMETERS`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unauthorized:
      description: |
        Authentication failed. `error.code` is one of
        `MISSING_API_KEY`, `INVALID_API_KEY`, `API_KEY_EXPIRED`, or
        `CLIENT_INACTIVE`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Forbidden:
      description: |
        Request is authenticated but not permitted. `error.code` is
        one of `MULTILINGUAL_NOT_ENABLED` or `IP_NOT_ALLOWED`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    RateLimited:
      description: |
        Too many requests. `error.code` is `RATE_LIMIT_EXCEEDED` (global
        per-minute cap) or `POLL_RATE_LIMITED` (poll endpoint only,
        same `requestId` polled faster than every 10 s). Respect the
        `Retry-After` header and `error.retryAfterSeconds`.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
