openapi: 3.1.0
info:
  title: Audra Voice API v2
  version: 2.0.0
  description: |
    Audra-native text-to-speech API with credit-based billing.

    **Catalog:** 150 voices across four engines and 10 Essential languages. List slugs with `GET /v2/voices`.

    **Models:** `audra-essential` (Essential), `audra-plus` (Plus), `audra-pro` (Pro), `audra-conversations` (Conversations).
    Legacy ids (`audra-core`, `audra-studio-lite`, `audra-studio`, `audra-studio-dialogue`) remain accepted and resolve to the canonical ids above.

    **Commercial use:** Free trial is evaluation-only. After purchasing credits, send
    `X-Audra-Commercial-Use: true` on production renders. Paid accounts receive
    `X-Commercial-Allowed: true` on successful speech responses.

    **Legacy v1:** `/v1/*` remains available with `Deprecation` and `Sunset` headers until sunset.
    See `/docs/v2` and `docs/MIGRATION_V1_V2.md`.
  contact:
    name: Audra
    url: https://audratalks.com/docs

servers:
  - url: https://audratalks.com
    description: Production
  - url: http://localhost:3000
    description: Local gateway

tags:
  - name: Auth
  - name: Speech
  - name: Billing
  - name: Meta

paths:
  /health:
    get:
      tags: [Meta]
      summary: Health check
      responses:
        '200':
          description: Gateway healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  tts:
                    type: object
                  version:
                    type: string

  /v2/accounts:
    post:
      tags: [Auth]
      summary: Create account and API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: API key issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignupResponse'
        '400':
          description: Invalid email
        '409':
          description: Email already registered
        '429':
          description: Signup rate limit (per IP)

  /v2/accounts/verify:
    get:
      tags: [Auth]
      summary: Verify email from signup link
      parameters:
        - in: query
          name: token
          required: true
          schema:
            type: string
      responses:
        '302':
          description: Redirect to dashboard with verified=1

  /v2/accounts/verify/resend:
    post:
      tags: [Auth]
      summary: Resend verification email
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Email sent or already verified
        '401':
          description: Unauthorized
        '429':
          description: Resend rate limit

  /v2/models:
    get:
      tags: [Meta]
      summary: List available models
      responses:
        '200':
          description: Model list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    type: string
                    example: list
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          example: audra-essential
                        tier:
                          type: string
                          example: standard
                        features:
                          type: array
                          items:
                            type: string

  /v2/stats/public:
    get:
      tags: [Meta]
      summary: Public signup and usage counters
      description: |
        Marketing-trust metrics for the landing page and agent crawlers.
        `accounts` and `chars_rendered` exclude automated test signups.
        `accounts_all` and `chars_rendered_all` include non-`@audra.dev` totals.
      responses:
        '200':
          description: Public stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicStats'

  /v2/voices:
    get:
      tags: [Meta]
      summary: List available voices (slug IDs)
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Voice list with capabilities
        '401':
          description: Missing or invalid API key

  /v2/usage:
    get:
      tags: [Billing]
      summary: Account usage and balance
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Usage snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageResponse'
        '401':
          description: Unauthorized

  /v2/billing/checkout:
    post:
      tags: [Billing]
      summary: Create Stripe Checkout session for credit pack
      security:
        - bearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                pack:
                  type: string
                  enum: [credits_5, credits_20, credits_100]
      responses:
        '200':
          description: Checkout URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri

  /v2/speech:
    post:
      tags: [Speech]
      summary: Synthesize speech
      security:
        - bearerAuth: []
      parameters:
        - in: header
          name: Idempotency-Key
          schema:
            type: string
        - in: header
          name: X-Audra-Commercial-Use
          schema:
            type: string
            enum: ['true', '1', 'yes']
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SpeechRequest'
            examples:
              english:
                summary: English Essential
                value:
                  model: audra-essential
                  voice: june
                  text: Hello world
                  format: mp3
                  speed: 1.0
              multilingual:
                summary: Spanish Essential
                value:
                  text: "Hola, ¿qué tal?"
                  voice: lucia
                  language: es
                  format: mp3
      responses:
        '200':
          description: Audio stream
          headers:
            X-Chars-Processed:
              schema:
                type: integer
            X-Audra-Model:
              schema:
                type: string
                example: audra-essential
            X-Audra-Voice:
              schema:
                type: string
                example: june
            X-Commercial-Allowed:
              schema:
                type: string
          content:
            audio/mpeg:
              schema:
                type: string
                format: binary
            audio/wav:
              schema:
                type: string
                format: binary
        '402':
          description: Insufficient credits or commercial use blocked
        '403':
          description: Premium voicepack requires paid account
        '413':
          description: Text exceeds sync character limit
        '422':
          description: Render failed

  /v2/speech/captioned:
    post:
      tags: [Speech]
      summary: Synthesize speech with word-level caption timestamps
      description: |
        Returns MP3/WAV plus estimated word timestamps in the `X-Captions-Json` response header.
        Timestamps are duration-weighted estimates (not forced alignment). Max **50,000** characters.
      security:
        - bearerAuth: []
      parameters:
        - in: header
          name: X-Audra-Commercial-Use
          schema:
            type: string
            enum: ['true', '1', 'yes']
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CaptionedSpeechRequest'
      responses:
        '200':
          description: Audio stream with caption metadata in headers
          headers:
            X-Chars-Processed:
              schema:
                type: integer
            X-Caption-Words:
              schema:
                type: integer
            X-Captions-Json:
              schema:
                type: string
              description: JSON object with `words`, `duration_seconds`, `voice`, `g2p_backend`
            X-Delivery-Profile:
              schema:
                type: string
          content:
            audio/mpeg:
              schema:
                type: string
                format: binary
            audio/wav:
              schema:
                type: string
                format: binary
        '400':
          description: Text exceeds 50k captioned limit
        '402':
          description: Insufficient credits
        '422':
          description: Render failed

  /v2/phonemize:
    post:
      tags: [Speech]
      summary: Preview phoneme output for text (debug / tuning)
      description: |
        Returns the phoneme string Audra Phonetics would use for the given voice.
        Supports inline IPA markup `[word](/ipa/)` when Audra Phonetics is active.
        Max **10,000** characters. Does not consume credits.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PhonemizeRequest'
      responses:
        '200':
          description: Phonemization result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhonemizeResponse'
        '400':
          description: Invalid voice or text too long
        '502':
          description: TTS worker unavailable

  /v2/lexicon:
    get:
      tags: [Lexicon]
      summary: List per-account pronunciation overrides
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Lexicon entries for the authenticated account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LexiconResponse'
    put:
      tags: [Lexicon]
      summary: Replace per-account pronunciation overrides
      description: Max 100 entries per account. Applied before shared finance lexicon on all speech renders.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LexiconPutRequest'
      responses:
        '200':
          description: Saved lexicon
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LexiconResponse'
        '400':
          description: Validation error or limit exceeded

  /v2/lexicon/{term}:
    delete:
      tags: [Lexicon]
      summary: Delete one pronunciation override by term
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: term
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Term removed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LexiconResponse'
        '404':
          description: Term not found

  /v2/voices/{slug}/probe:
    get:
      tags: [Voices]
      summary: Rate-limited voice health sample (no credits charged)
      description: Renders a 50-character sample via CPU worker. Limited to 10 probes per account per day.
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: slug
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Probe result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceProbeResponse'
        '400':
          description: Invalid voice slug
        '429':
          description: Daily probe limit exceeded
        '422':
          description: QA check failed
        '502':
          description: TTS worker unavailable

  /v2/speech/jobs:
    post:
      tags: [Speech]
      summary: Create an async speech render job
      description: |
        Queues text for background rendering — poll `status_url` until `status`
        is `completed`, then download `audio_url`. Use for long-form text where a
        synchronous response would time out (auto-triggered above ~8k chars on
        `POST /v2/speech` unless `X-Audra-Sync: true` is set).

        Supports three delivery modes via `mode` (or the `fast_mode`/`priority`/`economy`
        boolean aliases): `standard` (default), `fast` (queue priority, 1.25× credits),
        and `economy` (deferred/best-effort, 0.75× credits — see `SpeechJobRequest.mode`).
        `economy` is **only** available on this async endpoint; `POST /v2/speech` and
        other synchronous endpoints reject it with 400.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SpeechJobRequest'
            examples:
              multilingual:
                summary: Spanish async job
                value:
                  text: "Hola, ¿qué tal?"
                  voice: lucia
                  language: es
                  format: mp3
      responses:
        '202':
          description: Job queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SpeechJobResponse'
        '400':
          description: 'Invalid request, or mode "economy" requested on a sync-only endpoint'
        '402':
          description: Insufficient credits
        '413':
          description: Text exceeds 500k chars

  /v2/speech/jobs/{id}:
    get:
      tags: [Speech]
      summary: Poll async speech job status
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Job status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SpeechJobStatusResponse'
        '404':
          description: Job not found

  /v2/speech/jobs/{id}/audio:
    get:
      tags: [Speech]
      summary: Download completed async speech job audio
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Audio stream
          content:
            audio/mpeg:
              schema:
                type: string
                format: binary
            audio/wav:
              schema:
                type: string
                format: binary
        '404':
          description: Job not found or audio unavailable
        '409':
          description: Job not yet completed

  /v2/speech/batch:
    post:
      tags: [Speech]
      summary: Synthesize speech from pre-chunked segments
      security:
        - bearerAuth: []
      parameters:
        - in: header
          name: Idempotency-Key
          schema:
            type: string
        - in: header
          name: X-Audra-Commercial-Use
          schema:
            type: string
            enum: ['true', '1', 'yes']
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchSpeechRequest'
      responses:
        '200':
          description: Audio stream
          headers:
            X-Chars-Processed:
              schema:
                type: integer
            X-Batch-Segments:
              schema:
                type: integer
            X-Delivery-Profile:
              schema:
                type: string
          content:
            audio/mpeg:
              schema:
                type: string
                format: binary
        '402':
          description: Insufficient credits
        '413':
          description: Batch exceeds sync character limit
        '422':
          description: Render failed

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key from POST /v2/accounts

  schemas:
    SignupResponse:
      type: object
      properties:
        api_key:
          type: string
        credits_chars:
          type: integer
        email_verified:
          type: boolean
        message:
          type: string

    UsageResponse:
      type: object
      properties:
        credits_chars:
          type: integer
        lifetime_chars:
          type: integer
        monthly_chars:
          type: integer
        price_per_1k_chars:
          type: number
        price_per_1m_chars:
          type: number
        commercial:
          type: boolean
        email_verified:
          type: boolean
        upgrade_url:
          type: string
          nullable: true
        stripe_mode:
          type: string
          nullable: true
        premium_voicepacks_enabled:
          type: boolean

    SpeechRequest:
      type: object
      required: [text]
      properties:
        model:
          type: string
          default: audra-essential
        text:
          type: string
          maxLength: 500000
          description: |
            Plain text, SSML subset, Audra pause tags (`[pause:1.5s]`),
            or inline IPA markup `[word](/ipa/)` when Audra Phonetics is active.
        voice:
          type: string
          default: june
          description: |
            Voice slug from the catalog (150 voices / 10 Essential languages — list with
            `GET /v2/voices`) or weighted mix e.g. `june(2)+wren(1)`.
            Premium packs use product slugs (e.g. aurora-lead) when enabled.
        speed:
          type: number
          default: 1.0
          minimum: 0.5
          maximum: 2.0
        format:
          type: string
          enum: [mp3, wav]
          default: mp3
        delivery_profile:
          type: string
          enum: [legacy, podcast_v2, podcast_v3, podcast_v4]
          description: Podcast pacing profile (slower speed + paragraph/sentence pauses)
        normalize:
          type: boolean
          default: true
          description: |
            When true (default), normalize URLs, emails, currency, and percent signs before synthesis.
            Set false to pass text through unchanged (except pronunciation lexicon).
        language:
          type: string
          description: |
            BCP-47 language code for the requested voice. When omitted the service
            infers the language from the voice slug prefix (en-us, en-gb, ja, zh,
            es, pt-br, hi, it, fr-fr, de, nl). Provide this field explicitly when
            using designed/community voices that don't carry an unambiguous prefix,
            or when you want to force a specific G2P backend regardless of voice name.
          example: "fr-fr"
        render_mode:
          type: string
          enum: [fast, standard, gpu, cpu]
          description: |
            `fast` (default) = Modal GPU. `standard` = Modal CPU.
            Aliases `gpu` / `cpu` accepted.
        mode:
          type: string
          enum: [standard, fast]
          default: standard
          description: |
            `standard` — default queue placement. `fast` — queue priority, 1.25× credits.
            `economy` (0.75× credits, deferred) exists but is **not valid here** — it is
            only accepted on `POST /v2/speech/jobs` (async). Sending `mode: "economy"`
            or `economy: true` on this synchronous endpoint returns 400.

    SpeechJobRequest:
      type: object
      required: [text]
      properties:
        model:
          type: string
          default: audra-essential
        text:
          type: string
          maxLength: 500000
        voice:
          type: string
          default: june
        speed:
          type: number
          default: 1.0
          minimum: 0.5
          maximum: 2.0
        format:
          type: string
          enum: [mp3, wav]
          default: mp3
        webhook_url:
          type: string
          format: uri
          description: Optional callback POSTed with `{ job_id, status, chars }` on completion or failure.
        mode:
          type: string
          enum: [standard, fast, economy]
          default: standard
          description: |
            `standard` — default queue placement, 1.0× credits.
            `fast` — queue priority, 1.25× credits.
            `economy` — deferred/best-effort, 0.75× credits (25% discount). Runs on the
            next scheduled sweep (default every 12h) or sooner if a worker for the
            requested model is already warm from other traffic. Async jobs only —
            rejected with 400 on synchronous endpoints (`POST /v2/speech` and similar).
        fast_mode:
          type: boolean
          description: 'Alias for mode: "fast".'
        priority:
          type: boolean
          deprecated: true
          description: '@deprecated use mode: "fast".'
        economy:
          type: boolean
          description: 'Alias for mode: "economy".'
        language:
          type: string
          description: |
            BCP-47 language code. When omitted the service infers the language from
            the voice slug prefix. Provide this field explicitly for designed voices
            or to force a specific G2P backend.
          example: "fr-fr"
        delivery_profile:
          type: string
          enum: [off, auto, podcast]
        audio_post:
          type: string
          enum: [off, auto, broadcast, warm, podcast]

    SpeechJobResponse:
      type: object
      properties:
        job_id:
          type: string
        status:
          type: string
          example: queued
        chars:
          type: integer
          description: Billable characters after model and delivery-mode multipliers.
        input_chars:
          type: integer
        model:
          type: string
        mode:
          type: string
          enum: [standard, fast, economy]
        billing_multiplier:
          type: number
        status_url:
          type: string
          format: uri
        audio_url:
          type: string
          format: uri
        message:
          type: string
          description: |
            Human-readable timing guidance. Economy jobs get a sweep-interval-aware
            message instead of the standard/fast "typical N minutes" estimate.

    SpeechJobStatusResponse:
      type: object
      properties:
        job_id:
          type: string
        status:
          type: string
          enum: [queued, processing, completed, failed]
        chars:
          type: integer
        input_chars:
          type: integer
        model:
          type: string
        mode:
          type: string
          enum: [standard, fast, economy]
        voice:
          type: string
        format:
          type: string
        error:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        audio_url:
          type: string
          format: uri
          nullable: true

    CaptionedSpeechRequest:
      type: object
      required: [text]
      properties:
        model:
          type: string
          default: audra-essential
        text:
          type: string
          maxLength: 50000
        voice:
          type: string
          default: june
        speed:
          type: number
          default: 1.0
          minimum: 0.5
          maximum: 2.0
        format:
          type: string
          enum: [mp3, wav]
          default: mp3
        delivery_profile:
          type: string
          enum: [legacy, podcast_v2, podcast_v3, podcast_v4]
        normalize:
          type: boolean
          default: true
        language:
          type: string
          description: |
            BCP-47 language code. When omitted the service infers the language from
            the voice slug prefix. Provide explicitly for designed voices or to force
            a specific G2P backend.
          example: "fr-fr"
        render_mode:
          type: string
          enum: [fast, standard, gpu, cpu]

    PhonemizeRequest:
      type: object
      required: [text]
      properties:
        text:
          type: string
          maxLength: 10000
        voice:
          type: string
          default: june
        render_mode:
          type: string
          enum: [fast, standard, gpu, cpu]

    WordTimestamp:
      type: object
      properties:
        word:
          type: string
        start:
          type: number
          description: Start time in seconds
        end:
          type: number
          description: End time in seconds

    CaptionsPayload:
      type: object
      properties:
        words:
          type: array
          items:
            $ref: '#/components/schemas/WordTimestamp'
        duration_seconds:
          type: number
        voice:
          type: string
        delivery_profile:
          type: string
          nullable: true
        g2p_backend:
          type: string
        alignment:
          type: string
          example: duration-weighted
        alignment_note:
          type: string

    PhonemizeResponse:
      type: object
      properties:
        input:
          type: string
          description: |
            Text after normalization and pronunciation lexicon — what synthesis speaks from.
            When is_phonemes is true, this is the IPA phoneme string passed to the Essential engine.
        phonemes:
          type: string
          nullable: true
          description: |
            IPA phoneme string when is_phonemes is true. Null on the text path — G2P still runs
            inside synthesis; null does not mean phonemization was skipped.
        is_phonemes:
          type: boolean
          description: |
            True when input is an IPA phoneme string for direct synthesis. False when input is
            normalized text and G2P runs during render.
        backend:
          type: string
          description: Active G2P backend (e.g. audra-phonetics)
        british:
          type: boolean
        voice_key:
          type: string
          nullable: true
        supports_ipa_markup:
          type: boolean
          description: True when Essential inline [word](/ipa/) markup is supported on this worker.
        has_ipa_markup:
          type: boolean
          description: True when input contains inline IPA markup from the pronunciation lexicon.
        has_unknown_phonemes:
          type: boolean
          description: |
            True when Audra phonetics G2P emitted an unknown-token marker (❓). When true, do not ship
            audio until lexicon or script is fixed; synthesis may fall back to espeak.
        synthesis_path:
          type: string
          enum: [phonemes, ipa_markup, text]
          description: |
            phonemes — direct IPA synthesis; ipa_markup — lexicon [word](/ipa/) in input;
            text — normalized speakable text with runtime G2P.

    BatchSegment:
      type: object
      required: [text]
      properties:
        text:
          type: string
          maxLength: 4000
        speed:
          type: number
          minimum: 0.5
          maximum: 2.0
        pause_after_ms:
          type: integer
          minimum: 0
          maximum: 5000
          default: 0
        energy:
          type: string
          enum: [low, neutral, high]
          default: neutral
          description: |
            Per-segment delivery energy. Maps to a small speed multiplier on the segment
            (e.g. high ≈ +8% speed). Omit or use neutral for default pacing.

    BatchSpeechRequest:
      type: object
      required: [segments]
      properties:
        model:
          type: string
          default: audra-essential
        voice:
          type: string
          default: june
        speed:
          type: number
          default: 1.0
          minimum: 0.5
          maximum: 2.0
        crossfade_ms:
          type: integer
          default: 50
          minimum: 0
          maximum: 200
          description: >
            Crossfade duration between segments (ms). Pauses under 300ms use crossfade
            instead of inserted silence for smoother podcast beats.
        format:
          type: string
          enum: [mp3, wav]
          default: mp3
        delivery_profile:
          type: string
          enum: [legacy, podcast_v2, podcast_v3, podcast_v4]
        language:
          type: string
          description: |
            BCP-47 language code. When omitted the service infers the language from
            the voice slug prefix.
          example: "es"
        segments:
          type: array
          minItems: 1
          maxItems: 64
          items:
            $ref: '#/components/schemas/BatchSegment'

    LexiconEntry:
      type: object
      required: [term, spoken]
      properties:
        term:
          type: string
          maxLength: 64
          description: Word or phrase to match (case-insensitive word boundary)
        spoken:
          type: string
          maxLength: 120
          description: Speakable replacement text

    LexiconPutRequest:
      type: object
      required: [entries]
      properties:
        entries:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/LexiconEntry'

    LexiconResponse:
      type: object
      properties:
        entries:
          type: array
          items:
            $ref: '#/components/schemas/LexiconEntry'
        max_entries:
          type: integer
          example: 100

    VoiceProbeResponse:
      type: object
      properties:
        slug:
          type: string
        ok:
          type: boolean
        duration_ms:
          type: integer
        qa_passed:
          type: boolean
          nullable: true
        render_backend:
          type: string
          description: Tier that rendered audio (`audra-essential`, `audra-plus`, `audra-pro`, `audra-conversations`).
          example: audra-essential
        code:
          type: string
        message:
          type: string

    PublicStats:
      type: object
      properties:
        accounts:
          type: integer
          description: Real user accounts (excludes test/automation emails)
        chars_rendered:
          type: integer
          description: Lifetime chars rendered by real users
        accounts_all:
          type: integer
          description: All accounts except @audra.dev smoke signups
        chars_rendered_all:
          type: integer
          description: Lifetime chars including test traffic
