openapi: 3.1.0
info:
  title: ORAVYS Voice Analysis API
  version: 1.1.0
  description: |
    Voice authenticity analysis over HTTP. This document describes the analysis
    routes of the API and nothing else: every path below exists in the running
    application (a test compares this file with the Flask url_map, trk #906).
    Other routes mounted under /api/v1 are not part of the public contract.

    Authentication: send the API key in the `X-API-Key` header, or as
    `Authorization: Bearer <key>`. Keys in the query string or in form fields
    are refused.

    Consent: every call that sends a voice must carry `consent=true` (or the pair
    `consent_data_processing=true` and `consent_voice_processing=true`). By
    sending it you attest that the person whose voice is analyzed gave explicit
    consent (GDPR Art. 9.2.a, BIPA). Without it the call is refused with 400
    `consent_required` and nothing is counted against the quota.

    Scopes: the analysis routes require the `detect:audio` scope. Keys created
    before scopes existed keep their access.

    Quotas: Advanced plan keys have 1,000 API calls per month, Elite plan keys
    10,000. Beyond the quota the API answers 429 with code
    `monthly_quota_exceeded`. Polling or cancelling a job is not counted.
    Per-minute and per-day rate limits also apply (429 `rate_limit_exceeded`).

    Errors: every error of these routes has the same shape, see the `Error`
    schema. The fields `code` and `message` are also repeated at the top level
    for clients written against the former format, where `error` was a string.

    Results are advisory. Do not base a legal or financial decision on a single
    verdict.
  contact:
    name: ORAVYS API Support
    email: contact@oravys.com
    url: https://oravys.com/developers
  license:
    name: Proprietary
    url: https://app.oravys.com/legal/terms
  termsOfService: https://app.oravys.com/legal/terms

servers:
  - url: https://app.oravys.com
    description: Production

tags:
  - name: Analysis
    description: Voice analysis, synchronous and asynchronous.
  - name: Jobs
    description: Status and result of asynchronous analyses.
  - name: Health
    description: Liveness and readiness probes (no key required).

security:
  - ApiKeyAuth: []
  - BearerAuth: []

paths:

  /api/v1/analyze:
    post:
      tags: [Analysis]
      summary: Full voice analysis (synchronous, streamed)
      operationId: analyzeVoice
      x-required-scopes: [detect:audio]
      description: |
        Runs the full analysis and returns the verdict with a hosted report URL.
        The response is streamed: the server sends a space character about every
        15 seconds while the analysis runs, then one JSON object on the last
        line. Read the whole body and parse it after stripping leading
        whitespace. The server stops waiting after about 270 seconds and then
        returns the error `analysis_timeout`. For long recordings, prefer
        POST /api/v1/analyze/async.

        Missing consent, missing audio, missing scope and exhausted quota are
        answered before the stream starts, with their real HTTP status. An error
        that happens during the analysis arrives in the final JSON line with
        HTTP 200: check `success` and read `status` and `error`.
      parameters:
        - in: query
          name: explain
          required: false
          schema:
            type: boolean
            default: false
          description: When true, the result also contains a plain-language explanation of the verdict.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [audio, consent]
              properties:
                audio:
                  type: string
                  format: binary
                  description: Audio file (wav, mp3, flac, m4a, ogg, webm), up to 15 MB.
                consent:
                  type: boolean
                  description: |
                    Required, must be true. You attest that the person whose voice is
                    analyzed gave explicit consent. The pair consent_data_processing=true
                    and consent_voice_processing=true is accepted instead.
                consent_data_processing:
                  type: boolean
                consent_voice_processing:
                  type: boolean
                lang:
                  type: string
                  description: Language of the verdict label, for example "en" or "fr".
      responses:
        '200':
          description: Final JSON line of the stream (success, or an error with `success=false`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AnalysisResult'
                  - $ref: '#/components/schemas/StreamError'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /api/v1/analyze-guest:
    post:
      tags: [Analysis]
      summary: Deprecated alias of /api/v1/analyze
      operationId: analyzeGuest
      deprecated: true
      x-required-scopes: [detect:audio]
      description: Kept for older client builds. Same contract as POST /api/v1/analyze.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/AudioSubmission'
      responses:
        '200':
          description: Same as POST /api/v1/analyze.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AnalysisResult'
                  - $ref: '#/components/schemas/StreamError'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /api/v1/analyze/async:
    post:
      tags: [Analysis]
      summary: Submit a full analysis as a background job
      operationId: submitAnalysisJob
      x-required-scopes: [detect:audio]
      description: |
        Stores the audio and starts the analysis in the background. Answers
        immediately with a `job_id`; poll GET /api/v1/job/{job_id} until the
        status is `completed` or `failed`. Only the key that submitted a job can
        read or cancel it. Send an `Idempotency-Key` header to make a retried
        submission return the first answer instead of starting a second job.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/AudioSubmission'
      responses:
        '202':
          description: Job accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAccepted'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /api/v1/job/{job_id}:
    parameters:
      - in: path
        name: job_id
        required: true
        schema:
          type: string
    get:
      tags: [Jobs]
      summary: Status and result of a job
      operationId: getJob
      description: |
        Returns the status (`pending`, `running`, `completed`, `failed`) and the
        progress. When the status is `completed`, the response carries the HTML
        report. A job that belongs to another key answers 404 `job_not_found`.
        A 503 `result_not_retrievable` means the job finished but its result is
        not reachable from the instance that answered: retry shortly.
      responses:
        '200':
          description: Job state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          $ref: '#/components/responses/Unavailable'
    delete:
      tags: [Jobs]
      summary: Cancel a job
      operationId: cancelJob
      responses:
        '200':
          description: Job cancelled.
          content:
            application/json:
              schema:
                type: object
                required: [job_id, status]
                properties:
                  job_id:
                    type: string
                  status:
                    type: string
                    enum: [cancelled]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/verdict-only:
    post:
      tags: [Analysis]
      summary: Fast authenticity verdict (JSON only)
      operationId: verdictOnly
      x-required-scopes: [detect:audio]
      description: |
        Returns only a verdict (`authentic`, `synthetic` or `inconclusive`) and a
        confidence from 0 to 100, usually within seconds. No report is
        produced. Limited to 10 calls per hour per key, in addition to the
        monthly quota. Audio up to 25 MB.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/AudioSubmission'
      responses:
        '200':
          description: Verdict.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verdict'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /api/v1/health:
    get:
      tags: [Health]
      summary: Service health
      operationId: health
      security: []
      responses:
        '200':
          description: Service status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string

  /api/v1/health/live:
    get:
      tags: [Health]
      summary: Liveness probe
      operationId: healthLive
      security: []
      responses:
        '200':
          description: The process answers.

  /api/v1/health/ready:
    get:
      tags: [Health]
      summary: Readiness probe
      operationId: healthReady
      security: []
      responses:
        '200':
          description: The service is ready.
        '503':
          description: The service is not ready yet.

  /api/v1/openapi.yaml:
    get:
      tags: [Health]
      summary: This document
      operationId: openapiSpec
      security: []
      responses:
        '200':
          description: OpenAPI document in YAML.
          content:
            text/yaml:
              schema:
                type: string

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
    BearerAuth:
      type: http
      scheme: bearer
      description: "The API key itself, sent as `Authorization: Bearer <key>`."

  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema:
        type: string
        minLength: 8
        maxLength: 128
      description: |
        Optional. A retry with the same key and the same body within 24 hours
        returns the first answer (header `Idempotent-Replayed: true`). The same
        key with a different body answers 422 `idempotency_conflict`. Not
        applied to the streamed answer of POST /api/v1/analyze.

  schemas:
    AudioSubmission:
      type: object
      required: [audio, consent]
      properties:
        audio:
          type: string
          format: binary
          description: Audio file (wav, mp3, flac, m4a, ogg, webm). Up to 15 MB on /analyze and /analyze/async.
        consent:
          type: boolean
          description: |
            Required, must be true. You attest that the person whose voice is
            analyzed gave explicit consent. The pair consent_data_processing=true
            and consent_voice_processing=true is accepted instead.
        consent_data_processing:
          type: boolean
        consent_voice_processing:
          type: boolean
        lang:
          type: string
          description: Language of the verdict label, for example "en" or "fr".

    AnalysisResult:
      type: object
      required: [success, verdict]
      additionalProperties: true
      properties:
        success:
          type: boolean
        report_id:
          type: string
        report_url:
          type: string
          description: Path of the hosted report, relative to https://app.oravys.com.
        verification_url:
          type: string
        verdict:
          type: string
          enum: [authentic, synthetic, inconclusive]
        verdict_label:
          type: string
        verdict_source:
          type: string
          description: Always "ai-analysis". The verdict is produced by an automated system.
        confidence_score:
          type: number
          minimum: 0
          maximum: 1
        explanation:
          type: object
          description: Present only with explain=true.

    Verdict:
      type: object
      required: [verdict, confidence]
      additionalProperties: true
      description: Other fields may be present; they are diagnostic and not part of the stable contract.
      properties:
        verdict:
          type: string
          enum: [authentic, synthetic, inconclusive]
        verdict_label:
          type: string
        locale:
          type: string
        confidence:
          type: number
          minimum: 0
          maximum: 100
        elapsed_ms:
          type: integer
        verdict_signature:
          type: string
          description: Server-side signature of the verdict, for tamper evidence.

    JobAccepted:
      type: object
      required: [job_id, status, poll_url]
      properties:
        job_id:
          type: string
        status:
          type: string
          enum: [pending]
        poll_url:
          type: string

    Job:
      type: object
      required: [job_id, status, progress]
      properties:
        job_id:
          type: string
        status:
          type: string
          enum: [pending, running, completed, failed]
        progress:
          type: integer
          minimum: 0
          maximum: 100
        created_at:
          type: string
        updated_at:
          type: string
        design:
          type: string
        report_html:
          type: string
          description: Present when the status is completed.
        error:
          type: string
          description: Present when the status is failed (value analysis_failed).
        message:
          type: string

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, doc_url]
          additionalProperties: true
          properties:
            code:
              type: string
              pattern: '^[a-z][a-z0-9_]*$'
              description: |
                Stable machine-readable code. Codes returned by these routes
                include missing_api_key, invalid_api_key, insufficient_permissions,
                consent_required, missing_audio, empty_audio, invalid_audio,
                audio_decode_error, audio_too_large, job_not_found,
                result_not_retrievable, monthly_quota_exceeded,
                rate_limit_exceeded, idempotency_key_invalid,
                idempotency_conflict, analysis_timeout, internal_error.
            message:
              type: string
            doc_url:
              type: string
        code:
          type: string
          description: Copy of error.code, kept for clients of the former format.
        message:
          type: string
          description: Copy of error.message, kept for clients of the former format.

    StreamError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          required: [success, status]
          properties:
            success:
              type: boolean
              enum: [false]
            status:
              type: integer
              description: HTTP status the error would have had outside the stream.

  responses:
    BadRequest:
      description: Invalid request (consent_required, missing_audio, invalid_audio...).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: The key lacks the required scope (insufficient_permissions).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Unknown job, or job owned by another key (job_not_found).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PayloadTooLarge:
      description: Audio file too large (audio_too_large).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    TooManyRequests:
      description: Monthly quota (monthly_quota_exceeded) or rate limit (rate_limit_exceeded).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unavailable:
      description: Result temporarily not retrievable (result_not_retrievable).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
