openapi: 3.1.0
info:
  title: Voice Feeling Public API
  version: "1.0.0"
  summary: Emotional voice analysis, as a REST API.
  description: |
    Create emotional-voice analyses from an uploaded audio file or a URL, poll their
    status, download the normalized report, and subscribe to webhook events instead
    of polling.

    ## Conventions

    * All request and response bodies are JSON unless noted otherwise (audio upload
      uses `multipart/form-data`).
    * Every error response uses the same envelope: `{ "error": { "code", "message",
      "docs_url" } }`. Build integrations against `code`, not `message` — `message`
      is localized based on `Accept-Language` and may change wording over time.
    * Timestamps are ISO 8601 UTC strings.
    * Acoustic signals in a report are correlational indicators, not psychological
      facts, verdicts of truth or deception, or statements of intent. They must be
      combined with human context before informing any decision about a person.
  contact:
    name: Voice Feeling
    url: https://voice-feeling.pages.dev/en/developers/
    email: info@voicefeeling.com
servers:
  - url: https://voice-feeling.pages.dev
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Analyses
    description: Create and read emotional-voice analyses and their normalized reports.
  - name: Webhooks
    description: Register HTTPS endpoints that receive analysis lifecycle events.
  - name: Usage
    description: Read the organization's consumption against its plan limits.
paths:
  /api/v1/analyses:
    get:
      operationId: listAnalyses
      tags: [Analyses]
      summary: List analyses
      description: Returns the organization's analyses, most recent first.
      security:
        - bearerAuth: [analyses:read]
      parameters:
        - name: limit
          in: query
          description: Maximum number of items to return.
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          description: Opaque pagination cursor from a previous response's `next_cursor`.
          schema: { type: string }
        - name: status
          in: query
          description: Filter by analysis status.
          schema:
            type: string
            enum: [queued, processing, completed, failed]
      responses:
        "200":
          description: A page of analyses.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalysisList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
    post:
      operationId: createAnalysis
      tags: [Analyses]
      summary: Create an analysis
      description: |
        Accepts either an uploaded audio file (`multipart/form-data`, requires the
        `audios:write` scope in addition to `analyses:write`) or a JSON body with
        `audioUrl` pointing to audio you already host. `consentObtained: true` is
        required on every request: it confirms you have consent or another valid
        legal basis to analyze the recording. Supply an `Idempotency-Key` header so
        a network retry cannot create a duplicate analysis.
      security:
        - bearerAuth: [analyses:write]
        - bearerAuth: [analyses:write, audios:write]
      parameters:
        - name: Idempotency-Key
          in: header
          description: Recommended. For 24 hours an identical request returns the original analysis. Different bodies return `idempotency_conflict`; concurrent requests return `idempotency_in_progress` with Retry-After. File identity includes its content. Audio URLs should reference immutable recordings.
          schema: { type: string }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, consentObtained]
              properties:
                file:
                  type: string
                  format: binary
                  description: Audio file. See the Limits section for supported formats and the maximum size.
                consentObtained:
                  type: boolean
                  description: Must be `true`. Confirms consent or legal basis to analyze the recording.
                businessContext:
                  type: string
                  description: Optional free-form label used to organize results (e.g. `sales`, `service`, `collections`).
          application/json:
            schema:
              type: object
              required: [audioUrl, consentObtained]
              properties:
                audioUrl:
                  type: string
                  format: uri
                  description: Public https URL Voice Feeling can download the audio from.
                consentObtained:
                  type: boolean
                  description: Must be `true`. Confirms consent or legal basis to analyze the recording.
                businessContext:
                  type: string
                  description: Optional free-form label used to organize results.
      responses:
        "202":
          description: The analysis and its durable queue entry were accepted.
          content:
            application/json:
              schema:
                type: object
                required: [analysis, audio]
                properties:
                  analysis:
                    type: object
                    required: [id, status, createdAt, businessContext]
                    properties:
                      id: { type: string }
                      status: { type: string, enum: [queued] }
                      createdAt: { type: string, format: date-time }
                      businessContext: { type: string }
                  audio:
                    type: object
                    required: [id, bytes, contentType]
                    properties:
                      id: { type: string }
                      bytes: { type: integer }
                      contentType: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The key belongs to a different request, the same operation is still running, or its audio is no longer available. Retry-After is present for an operation in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413":
          description: The audio file, or the resource at `audioUrl`, exceeds the maximum allowed size.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "415":
          description: The file's content does not correspond to a supported audio format.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: The audio could not be downloaded or stored. Retry using the same idempotency key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
  /api/v1/analyses/{id}:
    get:
      operationId: getAnalysis
      tags: [Analyses]
      summary: Get an analysis
      description: Returns an analysis and its current status.
      security:
        - bearerAuth: [analyses:read]
      parameters:
        - $ref: "#/components/parameters/AnalysisId"
      responses:
        "200":
          description: The analysis.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Analysis" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
  /api/v1/analyses/{id}/report:
    get:
      operationId: getAnalysisReport
      tags: [Analyses]
      summary: Download the normalized report
      description: |
        Returns the normalized report (`voicefeeling.report.v1` contract) for a
        completed analysis. Only available once the analysis's status is
        `completed`; the report never contains the provider's raw response.
      security:
        - bearerAuth: [analyses:read]
      parameters:
        - $ref: "#/components/parameters/AnalysisId"
      responses:
        "200":
          description: The normalized report.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Report" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The analysis has not finished yet; the report is not available.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
  /api/v1/webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhook endpoints
      security:
        - bearerAuth: [webhooks:manage]
      responses:
        "200":
          description: The organization's registered webhook endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Register a webhook endpoint
      description: |
        Registers an HTTPS endpoint to receive analysis lifecycle events. The
        response includes `secret` once; it is not shown again and is required to
        verify each delivery's signature.
      security:
        - bearerAuth: [webhooks:manage]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: Public https URL that will receive event deliveries.
                events:
                  type: array
                  items: { $ref: "#/components/schemas/WebhookEvent" }
                  minItems: 1
      responses:
        "201":
          description: The webhook endpoint was registered.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Webhook"
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: Shown once. Used to compute the HMAC-SHA256 signature of each delivery.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The maximum number of webhook endpoints for this organization was reached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: The webhook URL is not a valid, public https URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
  /api/v1/webhooks/{id}:
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      security:
        - bearerAuth: [webhooks:manage]
      parameters:
        - name: id
          in: path
          required: true
          description: Webhook endpoint id.
          schema: { type: string }
      responses:
        "204":
          description: The webhook endpoint was deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
  /api/v1/usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Read the organization's usage
      description: Returns current consumption against the organization's plan limits.
      security:
        - bearerAuth: [usage:read]
      responses:
        "200":
          description: Usage snapshot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  day: { type: string, format: date }
                  month: { type: string, format: date }
                  usage:
                    type: object
                    properties:
                      uploads: { $ref: "#/components/schemas/UsageCounter" }
                      monthlyUploads: { $ref: "#/components/schemas/UsageCounter" }
                      analyses: { $ref: "#/components/schemas/UsageCounter" }
                      analysisMinutes: { $ref: "#/components/schemas/UsageCounter" }
                      realtimeMinutes: { $ref: "#/components/schemas/UsageCounter" }
                      storageBytes: { $ref: "#/components/schemas/UsageCounter" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
  /api/v1/me:
    get:
      operationId: getMe
      tags: [Usage]
      summary: Identify the API key and its organization
      description: Returns the organization behind the Bearer key, the key's name and scopes, and the current limits snapshot. Any scope is accepted.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Key identity.
          content:
            application/json:
              schema:
                type: object
                properties:
                  organization:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                  key:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      scopes: { type: array, items: { type: string } }
                  limits: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ApiDisabled" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "vf_live_<prefix>_<secret>"
      description: |
        API key in the `Authorization: Bearer vf_live_<prefix>_<secret>` header.
        Create and manage keys from the Voice Feeling portal, in the Developers
        section. Each key declares the scopes it can use; a request missing a
        required scope receives `api_scope_missing`.
  parameters:
    AnalysisId:
      name: id
      in: path
      required: true
      description: Analysis id.
      schema: { type: string }
  responses:
    BadRequest:
      description: The request body is malformed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: The API key is missing, invalid, expired, or revoked.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The API key is missing a required scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No resource with that id exists for this organization.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: The per-key request rate limit was exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ApiDisabled:
      description: The public API is disabled in this environment.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    UsageCounter:
      type: object
      required: [used, max]
      properties:
        used: { type: integer, minimum: 0 }
        max: { type: integer, minimum: 0, nullable: true }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable, machine-readable error code. See the developer docs' error-codes table.
            message:
              type: string
              description: Human-readable message, localized based on `Accept-Language`. Do not match against this field.
            docs_url:
              type: string
              format: uri
              description: Link to the error-codes section of the developer docs.
            required_scope:
              type: string
              description: Present on `api_scope_missing` — the scope the request was missing.
    Analysis:
      type: object
      properties:
        id: { type: string }
        status:
          type: string
          enum: [queued, processing, completed, failed]
        businessContext: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        completedAt: { type: string, format: date-time, nullable: true }
        reportUrl:
          type: string
          format: uri
          nullable: true
          description: Present once `status` is `completed`. Points to `GET /api/v1/analyses/{id}/report`.
      required: [id, status, createdAt]
    AnalysisList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Analysis" }
        next_cursor:
          type: string
          nullable: true
          description: Pass as `cursor` to fetch the next page; `null` when there are no more results.
      required: [data]
    Report:
      type: object
      description: |
        Summary of the `voicefeeling.report.v1` normalized contract. Acoustic
        signals are correlational indicators, not psychological facts or a
        verdict of truth or deception.
      properties:
        analysisId: { type: string }
        version:
          type: string
          const: "voicefeeling.report.v1"
        generatedAt: { type: string, format: date-time }
        durationSeconds: { type: number }
        segmentCount: { type: integer }
        signals:
          type: object
          description: Aggregated acoustic indicators for the interaction. Field set depends on the analysis configuration.
          additionalProperties: true
      required: [analysisId, version, generatedAt]
    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }
        status:
          type: string
          enum: [active, disabled]
        failureCount: { type: integer }
        createdAt: { type: string, format: date-time }
      required: [id, url, events, status, createdAt]
    WebhookEvent:
      type: string
      enum: [analysis.completed, analysis.failed]
      description: |
        `analysis.completed` — the analysis finished and the normalized report is
        available. `analysis.failed` — the analysis ended in failure after
        exhausting retries.
