openapi: 3.0.3
info:
  title: Everlit API
  description: |
    Everlit's public API. Two products on one key:

    - **TTS API** (`/v1/speech`): high-quality text-to-speech — send text, get
      back a production-ready MP3 narrated by any voice in your catalog,
      including your own cloned voices, with word-level timestamps.
    - **Articles API** (`/v1/articles`): publish a complete audio article from
      a URL or from text — extraction, narration in the publication's voice
      with byline, disclaimer and music, and a public player to link or embed.

    Both are also exposed as tools on the MCP server at `/v1/mcp`.

    Documentation: https://everlit.audio/docs/tts-api and https://everlit.audio/docs/articles-api
  version: 1.5.0
  contact:
    name: Everlit Support
    email: support@everlit.audio
    url: https://everlit.audio
  license:
    name: Proprietary
    url: https://everlit.audio/terms

servers:
  - url: https://api.everlit.audio
    description: Production

security:
  - BearerAuth: []

tags:
  - name: Speech
    description: Text-to-speech synthesis jobs
  - name: Articles
    description: Full audio-article conversions (Articles API)
  - name: Publications
  - name: Voices
  - name: Usage
  - name: Account
  - name: Playlists
  - name: Pronunciations

paths:
  /v1/speech:
    post:
      tags: [Speech]
      summary: Create a speech job
      operationId: createSpeech
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
          description: Any unique string; safe-retry semantics (Stripe convention).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSpeechRequest' }
      responses:
        '202':
          description: Job accepted
          headers:
            Location: { schema: { type: string } }
            RateLimit-Limit: { schema: { type: integer } }
            RateLimit-Remaining: { schema: { type: integer } }
            RateLimit-Reset: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Speech' }
        '200':
          description: Idempotent replay of an existing job (Idempotency-Replayed header set)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Speech' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unspeakable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
    get:
      tags: [Speech]
      summary: List speech jobs (newest first)
      operationId: listSpeech
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { name: starting_after, in: query, schema: { type: string }, description: 'Job id cursor' }
        - { name: ids, in: query, schema: { type: string }, description: 'Comma-separated job ids, max 50.' }
        - { name: idempotency_key, in: query, schema: { type: string }, description: 'Find the job created with this Idempotency-Key.' }
      responses:
        '200':
          description: Page of jobs
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Speech' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/audio/speech:
    post:
      tags: [Speech]
      summary: OpenAI-compatible synchronous speech
      description: >
        Synchronous wrapper over POST /v1/speech for OpenAI TTS clients: creates
        the same job, blocks the connection for it to finish (up to ~110s), and
        streams the audio bytes back. 4,096-character input limit; for longer
        text use POST /v1/speech instead.
      operationId: createSpeechOpenAiCompat
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OpenAiSpeechRequest' }
      responses:
        '200':
          description: The synthesized audio
          headers:
            X-Everlit-Speech-Id: { schema: { type: string } }
            X-Everlit-Audio-Url: { schema: { type: string } }
          content:
            audio/mpeg: { schema: { type: string, format: binary } }
            audio/wav: { schema: { type: string, format: binary } }
            audio/ogg: { schema: { type: string, format: binary } }
        '400':
          description: Invalid request
          content: { application/json: { schema: { $ref: '#/components/schemas/OpenAIError' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '504':
          description: Synthesis was still running when the wait timed out (see X-Everlit-Speech-Id)
          headers:
            X-Everlit-Speech-Id: { schema: { type: string } }
          content: { application/json: { schema: { $ref: '#/components/schemas/OpenAIError' } } }

  /v1/speech/estimate:
    post:
      tags: [Speech]
      summary: Price a request and predict its duration
      description: >
        Runs the same validation as POST /v1/speech and answers with billable
        characters, price, and the audio length predicted from the voice's
        measured speaking pace. Creates no job, reserves no quota, bills
        nothing; metered against the read rate limit.
      operationId: estimateSpeech
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SpeechEstimateRequest' }
      responses:
        '200':
          description: The estimate
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SpeechEstimate' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unspeakable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/speech/{id}:
    get:
      tags: [Speech]
      summary: Fetch a speech job
      operationId: getSpeech
      parameters: [ { $ref: '#/components/parameters/SpeechId' } ]
      responses:
        '200':
          description: The job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Speech' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Speech]
      summary: Delete a job's audio, or cancel a pending job (queued is free, processing is billed; usage records are retained)
      operationId: deleteSpeech
      parameters: [ { $ref: '#/components/parameters/SpeechId' } ]
      responses:
        '204': { description: Audio deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/speech/{id}/audio:
    get:
      tags: [Speech]
      summary: Download the audio
      description: |
        The deterministic download URL — returned as `audio_url` at creation,
        stable for the full 30-day retention. 302-redirects to short-lived
        storage; follow redirects.
      operationId: getSpeechAudio
      parameters: [ { $ref: '#/components/parameters/SpeechId' } ]
      responses:
        '302':
          description: Redirect to the audio bytes
          headers:
            Location: { schema: { type: string } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Gone' }

  /v1/speech/{id}/download:
    get:
      tags: [Speech]
      summary: Download the audio with a signed link (no API key)
      description: |
        The shareable link: returned as `download_url` on a job that has audio,
        valid 24 hours from the moment it was returned, and authenticated by
        its own `sig` rather than by a bearer key — so a person can click it.
        The signature is bound to this job id and this `exp`. 302-redirects to
        the audio as `audio/mpeg` with an inline disposition, so browsers play
        it. An invalid or expired link answers `403 invalid_download_link`;
        call `GET /v1/speech/{id}` for a fresh one.
      operationId: getSpeechSignedDownload
      security: []
      parameters:
        - { $ref: '#/components/parameters/SpeechId' }
        - name: exp
          in: query
          required: true
          description: Unix seconds at which the link stops working.
          schema: { type: integer }
        - name: sig
          in: query
          required: true
          description: HMAC-SHA256 of "<id>.<exp>", hex.
          schema: { type: string, pattern: '^[0-9a-f]{64}$' }
      responses:
        '302':
          description: Redirect to the audio bytes
          headers:
            Location: { schema: { type: string } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Gone' }

  /v1/speech/{id}/timings:
    get:
      tags: [Speech]
      summary: Word-level timestamps
      operationId: getSpeechTimings
      parameters: [ { $ref: '#/components/parameters/SpeechId' } ]
      responses:
        '200':
          description: Word timings
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SpeechTimings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Gone' }

  /v1/speech/{id}/captions:
    get:
      tags: [Speech]
      summary: Subtitles built from the job's word timings
      description: >
        SubRip, WebVTT, or the cue objects as JSON. Requires
        timings_available on the job; 404 timings_not_available otherwise.
      operationId: getSpeechCaptions
      parameters:
        - { $ref: '#/components/parameters/SpeechId' }
        - name: format
          in: query
          schema: { type: string, enum: [srt, vtt, json], default: srt }
        - name: max_chars
          in: query
          description: Characters per line.
          schema: { type: integer, minimum: 10, maximum: 120, default: 42 }
        - name: max_lines
          in: query
          description: Lines per cue.
          schema: { type: integer, minimum: 1, maximum: 3, default: 2 }
        - name: max_seconds
          in: query
          description: Longest a cue may stay on screen, in seconds.
          schema: { type: number, minimum: 1, maximum: 15, default: 5 }
      responses:
        '200':
          description: The captions
          headers:
            Content-Disposition: { schema: { type: string } }
          content:
            application/x-subrip: { schema: { type: string } }
            text/vtt: { schema: { type: string } }
            application/json:
              schema: { $ref: '#/components/schemas/SpeechCaptions' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Gone' }

  /v1/voices:
    get:
      tags: [Voices]
      summary: Voices available to your account
      operationId: listVoices
      parameters:
        - { name: language, in: query, schema: { type: string }, example: en }
        - { name: q, in: query, schema: { type: string }, description: 'Free-text match on name, style, accent or gender.' }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
      responses:
        '200':
          description: Voice list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Voice' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Voices]
      summary: Clone a voice from a sample recording
      description: |
        Clones a voice from a sample and adds it to your account. Send the
        sample either as a multipart upload (`file`) or as a `sample_url` we
        fetch (https, hostname, no IP literals) — one or the other, never
        both.

        The sample must be mp3/wav/m4a/aac/ogg/flac/webm, 25 MB or less, and
        at least 15 seconds of one speaker; 20-60 seconds of clean speech is
        ideal (we auto-trim to the best 4-8 second window).

        `consent` must be `true`: an attestation that the person whose voice
        this is agreed to have it cloned and used to generate speech on
        Everlit. It is recorded with `subject_name`, `relationship`, the
        optional `statement`, the API key, the timestamp, the request IP and
        a hash of the sample.

        The clone is usable as soon as it is created. Everlit can remove a
        clone afterwards for policy reasons, at which point its `status`
        becomes `removed` and synthesis with it is `403 voice_removed`.
        Counts against `limits.max_voice_clones`.
      operationId: createVoice
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [name, file, subject_name, relationship, consent]
              properties:
                name: { type: string, maxLength: 80, description: 'Display name for the voice.' }
                file: { type: string, format: binary, description: 'The sample recording.' }
                language: { type: string, description: 'ISO 639-1 code of the language spoken in the sample.', example: en }
                subject_name: { type: string, maxLength: 120, description: 'Name of the person whose voice this is.' }
                relationship: { type: string, enum: [self, employee, contractor, licensed, other] }
                consent: { type: boolean, description: 'Must be true.' }
                statement: { type: string, maxLength: 2000, description: 'Optional note on how consent was obtained.' }
          application/json:
            schema:
              type: object
              required: [name, sample_url, subject_name, relationship, consent]
              properties:
                name: { type: string, maxLength: 80, description: 'Display name for the voice.' }
                sample_url:
                  type: string
                  format: uri
                  description: 'https:// URL of the sample recording (hostname required, no IP literals).'
                  example: https://cdn.example.com/samples/dana.mp3
                language: { type: string, description: 'ISO 639-1 code of the language spoken in the sample.', example: en }
                subject_name: { type: string, maxLength: 120, description: 'Name of the person whose voice this is.' }
                relationship: { type: string, enum: [self, employee, contractor, licensed, other] }
                consent: { type: boolean, description: 'Must be true.' }
                statement: { type: string, maxLength: 2000, description: 'Optional note on how consent was obtained.' }
      responses:
        '201':
          description: The cloned voice
          headers:
            Location: { schema: { type: string }, description: 'Path of the new voice.' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Voice' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: >
            Wrong key type, SKU not enabled, or voice_clone_limit_reached
            (carries limit and used).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: voice_processing_failed — cloning failed and nothing was stored
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /v1/voices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Voices]
      summary: Fetch one voice
      description: >
        A catalog voice or one this account cloned. `status` is active or
        removed; a removed clone carries `removed` with the reason.
      operationId: getVoice
      responses:
        '200':
          description: The voice
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Voice' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Voices]
      summary: Delete a voice you cloned
      description: >
        Deletes the voice, its reference audio and its consent record, and
        frees a slot against limits.max_voice_clones. Audio already generated
        with the voice is unaffected. Catalog voices and other accounts'
        clones 404.
      operationId: deleteVoice
      responses:
        '204': { description: Deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/voices/{id}/preview:
    get:
      tags: [Voices]
      summary: Key-free demo clip of a catalog voice
      description: |
        No API key required. 302-redirects to a short demo MP3 (synthesized
        and cached on first request for a voice/language pair). `language`
        defaults to the voice's own. Only catalog voices are previewable —
        a cloned (publisher-owned) voice 404s. Per-IP rate limited.
      operationId: previewVoice
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: language
          in: query
          schema: { type: string }
          description: Defaults to the voice's own language.
      responses:
        '302':
          description: Redirect to the demo audio
          headers:
            Location: { schema: { type: string } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/mixables:
    get:
      tags: [Articles]
      summary: Music beds this account may use
      description: >
        Intro/outro beds `intro_music` / `outro_music` may reference —
        Everlit's library plus anything assigned to your publisher. No TTS
        entitlement required.
      operationId: listMixables
      parameters:
        - { name: kind, in: query, schema: { type: string }, description: 'Filter by kind, e.g. intro/outro.' }
        - { name: q, in: query, schema: { type: string }, description: 'Free-text match on name or tags.' }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
        - { name: starting_after, in: query, schema: { type: string } }
      responses:
        '200':
          description: Page of mixables
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Mixable' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/account:
    get:
      tags: [Account]
      summary: Who this key is, entitlements, billing, limits and plan
      description: >
        Needs no TTS entitlement — it is how a key finds out whether it has
        one. Call this first on a new key.
      operationId: getAccount
      responses:
        '200':
          description: The account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/playlists:
    get:
      tags: [Playlists]
      summary: Playlists in this account
      operationId: listPlaylists
      responses:
        '200':
          description: Page of playlists
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Playlist' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Playlists]
      summary: Create a playlist
      description: >
        Pin articles with `articles` (job ids or article ids), and/or give
        `tags` so newly published articles carrying one of them fill it
        automatically.
      operationId: createPlaylist
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              properties:
                name: { type: string }
                description: { type: string, nullable: true }
                publications: { type: array, items: { type: string }, description: 'Restrict to these publication ids (pblc_...).' }
                tags: { type: array, items: { type: string }, description: 'Fill rule: articles carrying any of these tags.' }
                articles: { type: array, items: { type: string }, description: 'Articles to pin: article ids (artl_...) or job ids (ajob_...).' }
      responses:
        '201':
          description: The created playlist
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Playlist' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/playlists/{id}:
    get:
      tags: [Playlists]
      summary: One playlist, with its resolved tracks and pins
      operationId: getPlaylist
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^(plist|slist)_[A-Za-z0-9]+$' }
      responses:
        '200':
          description: The playlist, plus tracks and pins
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Playlist'
                  - type: object
                    properties:
                      tracks:
                        type: array
                        items:
                          type: object
                          properties:
                            article_id: { type: string }
                            title: { type: string, nullable: true }
                            published_at: { type: string, format: date-time, nullable: true }
                            duration_seconds: { type: number, nullable: true }
                            publication: { type: string, nullable: true }
                            player_url: { type: string, format: uri }
                      pins:
                        type: array
                        items: { $ref: '#/components/schemas/PlaylistPin' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/playlists/{id}/articles:
    post:
      tags: [Playlists]
      summary: Pin an article to a playlist
      operationId: addToPlaylist
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [article]
              properties:
                article: { type: string, description: 'article id (artl_...) or job_id (ajob_...).' }
      responses:
        '201':
          description: The pin
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlaylistPin' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /v1/playlists/{id}/articles/{article}:
    delete:
      tags: [Playlists]
      summary: Unpin an article from a playlist
      description: Only removes a pin; an article a tag rule filled in cannot be removed this way.
      operationId: removeFromPlaylist
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: article
          in: path
          required: true
          description: 'article id (artl_...) or job_id (ajob_...).'
          schema: { type: string }
      responses:
        '200':
          description: The pin, marked deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlaylistPin' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/pronunciations:
    get:
      tags: [Pronunciations]
      summary: List this account's pronunciation rules
      operationId: listPronunciations
      parameters:
        - name: publication
          in: query
          schema: { type: string }
          description: Only rules that apply to this publication (its own plus account-wide).
      responses:
        '200':
          description: Page of pronunciation rules
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Pronunciation' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      tags: [Pronunciations]
      summary: Add or update a pronunciation rule
      description: Upserts on (account, word, publication).
      operationId: setPronunciation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [word, say]
              additionalProperties: false
              properties:
                word: { type: string, maxLength: 120, description: 'The text as written.' }
                say: { type: string, maxLength: 300, description: 'How to read it, spelled phonetically.' }
                exact_match: { type: boolean, default: false }
                publication: { type: string, nullable: true, description: 'Scope to this publication id; omit for account-wide.' }
      responses:
        '200':
          description: The rule
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Pronunciation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/pronunciations/{word}:
    delete:
      tags: [Pronunciations]
      summary: Remove a pronunciation rule
      operationId: deletePronunciation
      parameters:
        - name: word
          in: path
          required: true
          schema: { type: string }
        - name: publication
          in: query
          schema: { type: string }
          description: The publication the rule was scoped to; omit for an account-wide rule.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [pronunciation] }
                  word: { type: string }
                  publication: { type: string, nullable: true }
                  deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/access-requests:
    post:
      tags: [Account]
      summary: Ask for an Everlit account (no API key)
      description: >
        For a caller — typically an AI agent — whose user has no Everlit
        account. Everlit reviews every request, usually within a business day,
        and emails the user. Limited to 5 requests per minute per IP.
        Re-submitting the same email updates the existing request.
      operationId: createAccessRequest
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, use_case]
              properties:
                email: { type: string, format: email }
                use_case: { type: string, description: What will be narrated or published, and roughly how much. }
                name: { type: string }
                organization: { type: string }
                product: { type: string, enum: [tts, articles, both], default: both }
                expected_monthly_characters: { type: string, example: "2M characters" }
                client_name: { type: string, description: The app the user is connecting from. }
      responses:
        '201':
          description: The access request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccessRequest' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/access-requests/{id}:
    get:
      tags: [Account]
      summary: Status of an access request (no API key)
      operationId: getAccessRequest
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^acr_[a-z0-9]+$' }
      responses:
        '200':
          description: The access request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccessRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/languages:
    get:
      tags: [Voices]
      summary: Supported languages with billing multipliers and transcript availability
      description: >
        Each character of `text` bills at its script's multiplier
        (billable_characters = ceil of the per-character sum), so every
        language costs roughly the same per finished hour of audio. The
        `transcripts` flag tells you whether jobs in that language produce
        word-level timestamps via /timings.
      operationId: listLanguages
      responses:
        '200':
          description: Language list
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, example: zh }
                        name: { type: string, example: Chinese }
                        billing_multiplier: { type: number, example: 3.0 }
                        transcripts: { type: boolean, example: false }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/mcp:
    post:
      operationId: mcp
      summary: MCP server (Model Context Protocol)
      description: |
        Streamable HTTP MCP endpoint for AI agents (stateless, JSON responses).
        The request and response bodies are JSON-RPC 2.0 messages per the MCP
        specification; the tools mirror the REST endpoints one-to-one. See the
        "MCP" section of the TTS API guide.
      tags: [MCP]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: A single JSON-RPC 2.0 request.
      responses:
        '200':
          description: A JSON-RPC 2.0 response. Tool failures are returned as tool results with isError=true.
          content:
            application/json:
              schema:
                type: object
        '401':
          description: Missing or invalid API key.
        '403':
          description: Wrong key type or TTS API not enabled.
        '429':
          description: Read rate limit exceeded (read_rate_limit_exceeded); honor Retry-After.
  /v1/usage:
    get:
      tags: [Usage]
      summary: Usage aggregates
      operationId: getUsage
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: Month-to-date and daily usage
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/articles:
    post:
      tags: [Articles]
      summary: Create an audio article from a URL or from text
      operationId: createArticle
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
          description: Any unique string; safe-retry semantics (Stripe convention).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateArticleRequest' }
      responses:
        '202':
          description: Conversion accepted (status queued)
          headers:
            Location: { schema: { type: string } }
            RateLimit-Limit: { schema: { type: integer } }
            RateLimit-Remaining: { schema: { type: integer } }
            RateLimit-Reset: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Article' }
        '200':
          description: >-
            An article for this url already exists (status succeeded), a conversion for it is
            already running, or an Idempotency-Key replayed (Idempotency-Replayed header set)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Article' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unspeakable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
    get:
      tags: [Articles]
      summary: List articles (newest first)
      operationId: listArticles
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - name: starting_after
          in: query
          schema: { type: string }
          description: 'Cursor: return articles older than this id (artl_... or a job_id).'
        - { name: status, in: query, schema: { type: string, enum: [queued, processing, succeeded, failed] } }
        - { name: publication, in: query, schema: { type: string }, description: 'Filter by publication id' }
        - name: source
          in: query
          schema: { type: string, enum: [all, api, studio], default: all }
          description: >-
            all: API jobs plus every article the account's Studio holds. api: only jobs
            created through this API. studio: only what Studio holds.
        - { name: q, in: query, schema: { type: string }, description: 'Free-text match on id, job id, url, title, error code, metadata (Studio articles: id, title, url)' }
        - name: ids
          in: query
          schema: { type: string }
          description: 'Comma-separated ids, max 50; article ids (artl_...) and job ids (ajob_...) may be mixed.'
        - { name: idempotency_key, in: query, schema: { type: string }, description: 'Find the job created with this Idempotency-Key.' }
      responses:
        '200':
          description: Page of articles
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Article' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/articles/{id}:
    get:
      tags: [Articles]
      summary: Fetch an article by job id or article id
      operationId: getArticle
      parameters: [ { $ref: '#/components/parameters/ArticleId' } ]
      responses:
        '200':
          description: The article job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Article' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Articles]
      summary: Delete the article (soft delete), or cancel a running conversion
      description: |
        A finished job's published article is taken down (player and embed
        stop working); a queued/processing job is cancelled. The job record
        survives with status `deleted`. Idempotent; quota is not refunded.
      operationId: deleteArticle
      parameters: [ { $ref: '#/components/parameters/ArticleId' } ]
      responses:
        '204': { description: Deleted or cancelled }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/publications:
    get:
      tags: [Publications]
      summary: Publications this key can publish articles into
      operationId: listPublications
      responses:
        '200':
          description: Publications
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Publication' }
                  has_more: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/publications/{id}:
    get:
      tags: [Publications]
      summary: One publication and the Auto Audio settings its articles inherit
      operationId: getPublication
      parameters: [ { $ref: '#/components/parameters/PublicationId' } ]
      responses:
        '200':
          description: The publication
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicationDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Publications]
      summary: Change a publication's Auto Audio settings
      description: |
        Takes any subset. A field that is omitted or sent as null is left
        unchanged; voices and ui_disclaimers merge language by language, and
        author_voices replaces the whole list. "" turns off the fields with an
        off state (a language's disclaimer, intro_music, outro_music) and is a
        400 elsewhere; a music bed also takes "shuffle", a random track from
        the publication's intro/outro music. These settings apply to every future article in the
        publication, including ones the Auto Audio widget creates. Returns the
        updated publication.
      operationId: updatePublication
      parameters: [ { $ref: '#/components/parameters/PublicationId' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicationDefaults' }
      responses:
        '200':
          description: The updated publication
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicationDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: An `api_` key minted in Studio (API → Keys). Server-side only.

  parameters:
    ArticleId:
      name: id
      in: path
      required: true
      description: >-
        The article id (artl_...) or the job_id (ajob_...) the conversion returned. Any
        article in this account's Studio can be addressed by its artl_ id, whatever
        created it.
      schema: { type: string, pattern: '^(artl|ajob)_[A-Za-z0-9]+$' }
    SpeechId:
      name: id
      in: path
      required: true
      schema: { type: string }
      example: spch_01jxam5rk2v9q8w7e6r5t4y3u2
    PublicationId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^pblc_[A-Za-z0-9]+$' }
      example: pblc_abc123

  schemas:
    CreateArticleRequest:
      type: object
      required: [publication]
      description: Send url, or title + text, or all three (url is then only the canonical identifier).
      properties:
        publication: { type: string, description: 'Publication id (pblc_...) from GET /v1/publications' }
        url: { type: string, format: uri, maxLength: 2048, description: 'Public http(s) URL of the article to fetch and narrate', nullable: true }
        title: { type: string, maxLength: 500, nullable: true }
        text: { type: string, maxLength: 200000, description: 'Article body, plain text; with title, skips fetching', nullable: true }
        authors: { type: array, items: { type: string }, maxItems: 20, nullable: true }
        summary: { type: string, nullable: true }
        category: { type: string, nullable: true }
        tags: { type: array, items: { type: string }, maxItems: 50, nullable: true }
        art_url: { type: string, format: uri, nullable: true }
        published_at: { type: string, format: date-time, nullable: true }
        custom_byline: { type: string, nullable: true }
        voice: { type: string, description: 'Narrator voice id from GET /v1/voices', nullable: true }
        guest_voice: { type: string, description: 'Second voice, used when podcast_mode is on', nullable: true }
        language: { type: string, description: 'Language tag (en-US, es-MX) to narrate in, skipping detection', nullable: true }
        podcast_mode: { type: boolean, description: 'Two-voice conversational narration', nullable: true }
        music: { type: boolean, default: true, description: "Mix the publication's intro/outro beds into the audio", nullable: true }
        intro_music:
          type: string
          nullable: true
          description: >-
            The intro bed for this article: a mixable id, "shuffle" for a random
            track from the publication's intro/outro music, or "" for no intro.
        outro_music:
          type: string
          nullable: true
          description: >-
            The outro bed for this article: a mixable id, "shuffle" for a random
            track from the publication's intro/outro music, or "" for no outro.
        intro_music_duration: { type: number, description: 'Seconds of intro bed before the narration', nullable: true }
        outro_music_duration: { type: number, description: 'Seconds of outro bed after the narration', nullable: true }
        intro_music_pad: { type: number, description: 'Seconds the intro bed overlaps the narration', nullable: true }
        outro_music_pad: { type: number, description: 'Seconds the outro bed overlaps the narration', nullable: true }
        read_urls: { type: boolean, nullable: true }
        read_alt_text: { type: boolean, nullable: true }
        read_author: { type: boolean, nullable: true }
        audio_polish: { type: boolean, description: 'Rewrite the text for the ear before narrating', nullable: true }
        audio_polish_level: { type: string, enum: ['0', '1', '2', '3'], description: 'How far Audio Polish may rewrite; default "2"', nullable: true }
        ui_disclaimer: { type: boolean, description: 'Show the AI disclosure line under the player', nullable: true }
        conversation_mode: { type: boolean, deprecated: true, description: 'Alias of podcast_mode', nullable: true }
        mix: { type: boolean, deprecated: true, description: 'Alias of music', nullable: true }
        intro_mixable: { type: string, deprecated: true, description: 'Alias of intro_music', nullable: true }
        outro_mixable: { type: string, deprecated: true, description: 'Alias of outro_music', nullable: true }
        sonic_optimizer: { type: boolean, deprecated: true, description: 'Alias of audio_polish', nullable: true }
        disclaimer:
          type: string
          nullable: true
          description: >-
            Overrides the publication's spoken AI disclaimer text. An empty
            string ("") renders no spoken disclaimer for this article. Omitting
            the key, or sending null, uses the publication default and never
            changes the disclaimer setting stored on the article. The strings
            "false", "null", "none" and "off" are rejected with 400, because
            they would otherwise be narrated aloud.
        disclaimer_voice: { type: string, nullable: true }
        regenerate: { type: boolean, default: false, description: 'Re-narrate even if an article for this url exists', nullable: true }
        callback_url: { type: string, format: uri, description: 'https:// URL to POST the finished article to', nullable: true }
        metadata:
          type: object
          nullable: true
          additionalProperties: { type: string }
          maxProperties: 8
      additionalProperties: false
    Article:
      type: object
      properties:
        object: { type: string, enum: [article] }
        id:
          type: string
          nullable: true
          example: artl_9f3k2q9m7v4n6b1c5d8e0h
          description: 'The published Everlit article (artl_...); null until the article exists'
        job_id:
          type: string
          nullable: true
          example: ajob_01jx8f3k2q9m7v4n6b1c5d8e0h
          description: 'The conversion job this API ran; null on an article it did not convert'
        status: { type: string, enum: [queued, processing, succeeded, failed, deleted] }
        source:
          type: string
          enum: [api, studio]
          description: >-
            api: a job created through this API. studio: an article the account's Studio
            holds that this API did not create (the Auto Audio widget, WordPress, RSS,
            a Studio upload) — it has no job_id and characters is null.
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        publication: { type: string }
        url: { type: string, nullable: true }
        title: { type: string, nullable: true }
        language: { type: string, nullable: true }
        voice: { type: string, nullable: true }
        characters: { type: integer, nullable: true }
        player_url: { type: string, format: uri, nullable: true, description: 'Public player page' }
        embed_html: { type: string, nullable: true, description: 'Player iframe markup' }
        duration_seconds: { type: number, nullable: true }
        regenerate: { type: boolean }
        deleted_at: { type: string, format: date-time, nullable: true }
        callback_url: { type: string, nullable: true }
        error:
          type: object
          nullable: true
          properties:
            code: { type: string }
            message: { type: string }
        metadata:
          type: object
          additionalProperties: { type: string }
        published_at: { type: string, format: date-time, nullable: true, description: 'When the article was published; null until it is' }
        privacy: { type: string, nullable: true }
        tags:
          type: array
          items: { type: string }
        summary: { type: string, nullable: true }
        authors:
          type: array
          items: { type: string }
        cover: { type: string, nullable: true, description: 'Cover image URL' }
    Publication:
      type: object
      properties:
        object: { type: string, enum: [publication] }
        id: { type: string, example: pblc_abc123 }
        name: { type: string, nullable: true }
        url: { type: string, nullable: true }
        default_voice: { type: string, nullable: true }
        language_voices:
          type: array
          items: { type: string }
          description: 'Languages with their own configured narrator; these win over defaults.voice for content detected in them'
    PublicationDetail:
      allOf:
        - $ref: '#/components/schemas/Publication'
        - type: object
          properties:
            defaults: { $ref: '#/components/schemas/PublicationDefaults' }
    PublicationDefaults:
      type: object
      additionalProperties: false
      description: >-
        Everything a publication hands its articles. Voices, spoken disclaimers and
        the on-player disclosure line are per language tag, because that is how
        narration is chosen — the podcast_mode guest voice included. On PATCH, an
        omitted or null field is unchanged, voices and ui_disclaimers merge language
        by language, author_voices replaces the whole list, and "" turns off the
        fields with an off state (a disclaimer, a music bed). Booleans take true or
        false only.
      properties:
        language:
          type: string
          nullable: true
          description: 'Language tag to narrate in, skipping detection. Must be a key of voices; "" detects per article.'
        voices:
          type: object
          description: 'Keyed by language tag (en-US, es-MX, es, or the catch-all "default"). Set a language to null to remove it.'
          additionalProperties:
            type: object
            nullable: true
            additionalProperties: false
            properties:
              voice: { type: string, description: 'Narrator voice id for this language; cannot be blank' }
              guest_voice: { type: string, description: 'Second voice for this language, used in podcast_mode; cannot be blank (unset means one is chosen when a dialogue is produced)' }
              disclaimer: { type: string, description: 'Spoken AI disclaimer; "" narrates none' }
              disclaimer_voice: { type: string, description: 'Voice that reads it; "" only while the disclaimer is blank too' }
        author_voices:
          type: array
          description: "A byline's own voice, which beats the language voice. Replaces the whole list; [] clears it."
          items:
            type: object
            additionalProperties: false
            required: [author, voice]
            properties:
              author: { type: string, description: 'Author display name; matched ignoring case' }
              voice: { type: string }
        ui_disclaimers:
          type: object
          description: 'Per language: the AI disclosure line under the player. "" shows none; null drops the language.'
          additionalProperties: { type: string, nullable: true }
        music:
          type: boolean
          description: >-
            Shorthand for turning both intro_music and outro_music off (false) or
            back on (true). Derived, not stored: reading it means "at least one bed
            plays", and writing it leaves the bed ids alone. Sending it alongside an
            intro_music / outro_music that disagrees is a 400. Audio is always
            delivered to the player pre-mixed; there is no separate mix switch.
        intro_music:
          type: string
          nullable: true
          description: >-
            The intro bed every article gets: a mixable id, "shuffle" for a
            random track from the publication's intro/outro music, or "" for no
            intro. Read back in the same three forms.
        outro_music:
          type: string
          nullable: true
          description: >-
            The outro bed every article gets: a mixable id, "shuffle" for a
            random track from the publication's intro/outro music, or "" for no
            outro. Setting an id clears a shuffle; a shuffle keeps the id.
        intro_music_duration: { type: number, nullable: true }
        outro_music_duration: { type: number, nullable: true }
        intro_music_pad: { type: number, nullable: true }
        outro_music_pad: { type: number, nullable: true }
        audio_polish: { type: boolean, description: 'Rewrite the text for the ear before narrating' }
        audio_polish_level: { type: string, nullable: true, enum: ['0', '1', '2', '3'], description: 'Default "2"' }
        podcast_mode: { type: boolean, description: 'Two-voice conversational narration, using guest_voice' }
        read_urls: { type: boolean }
        read_alt_text: { type: boolean }
        read_author: { type: boolean }
        pauses:
          type: object
          additionalProperties: false
          description: >-
            Silence after each meta field: seconds as a number, or [min, max] for a
            range picked per article. null or "" restores our default — the one place
            "" is not an "off", because 0 is a real value.
          properties:
            title: { $ref: '#/components/schemas/PauseValue' }
            subtitle: { $ref: '#/components/schemas/PauseValue' }
            byline: { $ref: '#/components/schemas/PauseValue' }
            disclaimer: { $ref: '#/components/schemas/PauseValue' }
            header_to_body: { $ref: '#/components/schemas/PauseValue' }
        player:
          type: object
          additionalProperties: false
          properties:
            size: { type: string, enum: [large, small] }
            cover_art: { type: boolean, description: "Show the article's cover art in the player" }
            title_intro: { type: string, description: 'Line above the title, e.g. "Listen Now:". "" shows none.' }
            title_icon: { type: string, nullable: true, description: 'Icon beside the title ("headphones"). "" shows none.' }
        studio:
          type: object
          additionalProperties: false
          properties:
            read_along: { type: boolean, description: 'Highlight words during preview playback in Studio' }
        sharing:
          type: object
          additionalProperties: false
          properties:
            enabled: { type: boolean, description: 'Show the share button on the player' }
            link: { type: string, enum: [canonical, canonical_hosted, hosted], description: 'Which URL it shares' }
        email_capture:
          type: object
          additionalProperties: false
          properties:
            enabled: { type: boolean }
            label: { type: string, nullable: true, maxLength: 30 }
            prompt: { type: string, nullable: true, maxLength: 120 }
    PauseValue:
      nullable: true
      oneOf:
        - { type: number }
        - { type: array, items: { type: number }, minItems: 2, maxItems: 2 }
        - { type: string, enum: [''], description: 'Restores the default, same as null' }
    CreateSpeechRequest:
      type: object
      required: [text, voice]
      additionalProperties: false
      properties:
        text:
          type: string
          description: >
            Text to narrate. Billed on script-weighted characters as
            submitted — see GET /v1/languages for per-script multipliers.
            Inline markup (case-insensitive): [everlit-pause:N]
            for a pause of N seconds (0.1-10); [everlit-mixable:N] plays music
            bed N in full at that point and [everlit-mixable-background:N]
            ducks it under the text that follows (ids from GET /v1/mixables);
            [everlit-subvoice-open:N] ... [everlit-subvoice-close] narrates the
            passage in voice N. Every id is validated against the account.
        voice:
          type: string
          description: Voice id from GET /v1/voices.
          example: "669"
        language:
          type: string
          example: en
          nullable: true
          description: >
            ISO 639-1 hint; see GET /v1/languages. Never lowers the billing
            rate (only disambiguates Japanese vs Chinese Han readings).
        format:
          type: string
          enum: [mp3, wav, opus]
          default: mp3
          description: mp3 (44.1 kHz), wav (16-bit 44.1 kHz), or opus in an Ogg container (48 kHz).
        bitrate:
          type: integer
          nullable: true
          description: >
            kbps. mp3: 64, 96, 128 (default), 192, 256, 320; opus: 32, 48, 64
            (default), 96, 128. Rejected for wav, which is uncompressed.
        speed:
          type: number
          minimum: 0.5
          maximum: 2.0
          default: 1.0
          description: Applied after synthesis; word timings are scaled to match.
        paragraph_pause:
          type: number
          minimum: 0
          maximum: 5
          nullable: true
          description: Seconds of silence at every blank-line paragraph break. Omit for a natural 0.3-0.5 s.
        pronunciations:
          type: object
          nullable: true
          maxProperties: 50
          additionalProperties: { type: string }
          description: >
            Up to 50 written -> spoken respellings applied to this request only
            (whole-word, case-insensitive). For permanent rules use
            PUT /v1/pronunciations.
        callback_url:
          type: string
          format: uri
          nullable: true
          description: https URL that receives the finished job (signed).
        metadata:
          type: object
          nullable: true
          maxProperties: 8
          additionalProperties: { type: string }

    OpenAiSpeechRequest:
      type: object
      required: [input, voice]
      additionalProperties: false
      properties:
        input:
          type: string
          maxLength: 4096
          description: Text to narrate, up to 4,096 characters. For longer text use POST /v1/speech.
        voice:
          type: string
          description: An Everlit voice id (GET /v1/voices) — not an OpenAI voice name.
          example: "669"
        response_format: { type: string, enum: [mp3, wav, opus], default: mp3 }
        speed: { type: number, minimum: 0.5, maximum: 2.0, default: 1.0 }
        model:
          type: string
          nullable: true
          description: Accepted for OpenAI SDK compatibility; ignored.
        instructions:
          type: string
          nullable: true
          description: Accepted for OpenAI SDK compatibility; ignored.

    Speech:
      type: object
      properties:
        object: { type: string, enum: [speech] }
        id: { type: string, example: spch_01jxam5rk2v9q8w7e6r5t4y3u2 }
        status: { type: string, enum: [queued, processing, succeeded, failed, expired, deleted] }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        voice: { type: string }
        voice_name: { type: string, nullable: true, description: 'Display name of voice at read time.' }
        language: { type: string, nullable: true }
        format: { type: string }
        audio_url:
          type: string
          description: Deterministic download URL, stable for the retention window. Requires the API key.
        download_url:
          type: string
          nullable: true
          description: >
            Signed, key-free link to the audio —
            `{base}/v1/speech/{id}/download?exp={unix seconds}&sig={hex hmac}`.
            Null until the job has audio (and after deletion/retention). Valid
            24 hours from the moment it was returned; every read of the job
            mints a fresh one. Not a stable identifier — use audio_url for that.
          example: https://api.everlit.audio/v1/speech/spch_01jxam5rk2v9q8w7e6r5t4y3u2/download?exp=1757462400&sig=9f0c1d2e3a4b5c6d7e8f9012a3b4c5d6e7f80912a3b4c5d6e7f80912a3b4c5d6
        timings_available: { type: boolean }
        usage:
          type: object
          properties:
            characters: { type: integer }
            billable_characters: { type: integer, nullable: true }
            audio_seconds: { type: number, nullable: true }
        audio:
          type: object
          nullable: true
          properties:
            content_type: { type: string, example: audio/mpeg }
            bytes: { type: integer }
            duration_seconds: { type: number }
        error:
          type: object
          nullable: true
          properties:
            code: { type: string }
            message: { type: string }
        options:
          type: object
          description: The delivery options this job was created with.
          properties:
            speed: { type: number, example: 1.0 }
            bitrate: { type: integer, nullable: true, example: 128 }
            paragraph_pause: { type: number, nullable: true }
            pronunciations_count: { type: integer, nullable: true }
        metadata: { type: object, additionalProperties: { type: string } }

    SpeechEstimateRequest:
      type: object
      required: [text, voice]
      additionalProperties: false
      properties:
        text: { type: string, description: 'Text to price, as it would be submitted to POST /v1/speech.' }
        voice: { type: string, description: 'Voice id from GET /v1/voices.', example: "669" }
        language: { type: string, nullable: true, description: 'ISO 639-1 hint; see GET /v1/languages.' }
        paragraph_pause:
          type: number
          description: The paragraph_pause you will pass to POST /v1/speech (0-5 s); added at each blank-line break.
        speed: { type: number, minimum: 0.5, maximum: 2.0, default: 1.0 }

    SpeechEstimate:
      type: object
      properties:
        object: { type: string, enum: [speech_estimate] }
        voice: { type: string, example: "669" }
        language: { type: string, nullable: true }
        characters: { type: integer, description: 'Characters as submitted.' }
        billable_characters: { type: integer, description: 'Script-weighted characters, what you are billed on.' }
        price_cents: { type: number }
        price: { type: string, example: "$0.0002" }
        estimated_duration_seconds: { type: number }
        explicit_pause_seconds:
          type: number
          description: Seconds added for [everlit-pause:N] tags and paragraph_pause at blank-line breaks, divided by speed. Already included in estimated_duration_seconds.
        paragraph_pause: { type: number, nullable: true }
        duration_basis:
          type: string
          enum: [voice_pacing, catalog_default]
          description: >
            voice_pacing: predicted from this voice's measured pace.
            catalog_default: no measurement yet, the catalog average was used.
        speed: { type: number }

    SpeechCaptions:
      type: object
      description: The format=json answer of GET /v1/speech/{id}/captions.
      properties:
        object: { type: string, enum: [captions] }
        id: { type: string }
        format: { type: string, enum: [json] }
        cues:
          type: array
          items:
            type: object
            properties:
              index: { type: integer, description: '1-based cue number.' }
              start: { type: number }
              end: { type: number }
              text: { type: string, description: 'Cue text; lines separated by newlines.' }

    SpeechTimings:
      type: object
      properties:
        object: { type: string, enum: [speech.timings] }
        id: { type: string }
        duration_seconds: { type: number }
        words:
          type: array
          items:
            type: object
            properties:
              word: { type: string }
              start: { type: number }
              end: { type: number }

    Voice:
      type: object
      properties:
        id: { type: string, example: "669" }
        name: { type: string }
        style: { type: string }
        language: { type: string, nullable: true }
        languages: { type: array, items: { type: string }, description: 'Every language this voice speaks (more than one when is_multilingual).' }
        is_multilingual: { type: boolean }
        accent: { type: string, nullable: true }
        gender: { type: string, nullable: true }
        owned_by: { type: string, enum: [everlit, publisher] }
        preview_url:
          type: string
          format: uri
          nullable: true
          description: Key-free demo clip (GET /v1/voices/{id}/preview). Null for publisher-owned (cloned) voices.
        pacing:
          type: object
          nullable: true
          description: >
            This voice's measured speaking pace, in billable (script-weighted)
            characters per second. Null when we have not measured it yet;
            POST /v1/speech/estimate falls back to the catalog average then.
          properties:
            characters_per_second: { type: number, example: 16.8 }
            measured_at: { type: string, format: date-time, nullable: true }
            source:
              type: string
              enum: [jobs, calibration]
              description: jobs = measured from this voice's recent jobs; calibration = from a synthesized passage.
        status:
          type: string
          enum: [active, removed]
          description: >
            active for catalog voices and for clones that can narrate. A clone
            Everlit removed is removed and can no longer narrate
            (403 voice_removed); delete it to free the slot.
        removed:
          type: object
          nullable: true
          description: Why and when Everlit removed this clone. Null unless status is removed.
          properties:
            at: { type: string, format: date-time, nullable: true }
            reason:
              type: string
              nullable: true
              enum: [policy_violation, impersonation, rights_complaint, abuse, other]
            message: { type: string, nullable: true, description: The client-facing label for the reason. }
            note: { type: string, nullable: true }
        consent:
          type: object
          nullable: true
          description: The consent attestation recorded when the voice was cloned. Null for catalog voices.
          properties:
            subject_name: { type: string, nullable: true }
            relationship: { type: string, enum: [self, employee, contractor, licensed, other], nullable: true }
            attested_at: { type: string, format: date-time, nullable: true }

    Mixable:
      type: object
      properties:
        id: { type: string, example: "42" }
        name: { type: string, description: 'Display name (filename without its extension).' }
        kind: { type: string, example: intro }
        tags: { type: object, additionalProperties: true }
        owned_by: { type: string, enum: [everlit, publisher] }
        preview_url: { type: string, format: uri, nullable: true }

    Playlist:
      type: object
      properties:
        object: { type: string, enum: [playlist] }
        id: { type: string, example: plist_a1b2c3 }
        name: { type: string, nullable: true }
        description: { type: string, nullable: true }
        tags: { type: array, items: { type: string } }
        match: { type: string, nullable: true, description: 'How the tag rule combines tags (e.g. any).' }
        size: { type: integer, nullable: true }
        order: { type: string, nullable: true }
        publications:
          type: array
          items: { type: string }
          description: Restricted to these publication ids; empty means unrestricted.
        embed_url: { type: string, format: uri, nullable: true }
        embed_html: { type: string, nullable: true }
        feed_url: { type: string, format: uri, description: The RSS feed creator serves for this playlist. }
        feed_distributed: { type: boolean, description: Whether the feed has been submitted to podcast platforms. }

    PlaylistPin:
      type: object
      properties:
        object: { type: string, enum: [playlist_pin] }
        playlist: { type: string }
        id: { type: string, nullable: true }
        article_id: { type: string }
        position: { type: integer, nullable: true }
        deleted: { type: boolean }

    Pronunciation:
      type: object
      properties:
        object: { type: string, enum: [pronunciation] }
        word: { type: string }
        say: { type: string }
        exact_match: { type: boolean }
        publication: { type: string, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }

    AccessRequest:
      type: object
      properties:
        object: { type: string, enum: [access_request] }
        id: { type: string, example: acr_k3j9x2m8q1w5 }
        status: { type: string, enum: [pending, approved, denied] }
        created_at: { type: string, format: date-time }
        approved_at: { type: string, format: date-time, nullable: true }
        denied_at: { type: string, format: date-time, nullable: true }
        status_url: { type: string, format: uri, description: Poll this (GET, no key). }
        connect_url: { type: string, format: uri, description: The MCP endpoint to connect to once approved. }
        docs_url: { type: string, format: uri }
        next_step: { type: string, enum: [wait, authorize_oauth, contact_support] }
        poll_after_seconds: { type: integer, nullable: true, description: Present while pending (3600). }
        message: { type: string }

    Account:
      type: object
      properties:
        object: { type: string, enum: [account] }
        publisher:
          type: object
          properties:
            id: { type: string, example: pbls_abc123 }
            name: { type: string, nullable: true }
        api_key:
          type: object
          properties:
            id: { type: integer }
            name: { type: string, nullable: true }
            oauth: { type: boolean }
            expires_at: { type: string, format: date-time, nullable: true }
            publications: { type: array, items: { type: string } }
        entitlements:
          type: object
          properties:
            tts_api: { type: boolean }
            tts_reason: { type: string, enum: [payg, comped, none] }
            articles_api: { type: boolean }
        billing:
          type: object
          properties:
            mode: { type: string, enum: [payg, comped, none] }
            price_cents_per_million: { type: integer }
            balance_cents: { type: number, nullable: true }
            estimated_characters_remaining: { type: integer, nullable: true }
            billing_url: { type: string, format: uri }
        limits:
          type: object
          properties:
            requests_per_minute: { type: integer, nullable: true }
            reads_per_minute: { type: integer, nullable: true }
            max_concurrent_jobs: { type: integer, nullable: true }
            max_characters_per_request: { type: integer, nullable: true }
            max_voice_clones: { type: integer, nullable: true, description: 'How many cloned voices this account may hold at once.' }
        usage:
          type: object
          properties:
            month_to_date_characters: { type: integer, nullable: true }
            voice_clones: { type: integer, nullable: true, description: 'Cloned voices this account holds now.' }
        plan:
          type: object
          properties:
            tier: { type: string, nullable: true }
            publications: { type: integer, nullable: true }
            articles_per_month: { type: integer, nullable: true }
        server:
          type: object
          properties:
            version: { type: string, example: "1.8.0" }
            docs_url: { type: string, format: uri }
            status_url: { type: string, format: uri }
            request_access_url: { type: string, format: uri }

    UsageSummary:
      type: object
      properties:
        object: { type: string, enum: [usage] }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        month_to_date_characters: { type: integer, nullable: true }
        monthly_character_limit: { type: integer, nullable: true }
        billing_mode: { type: string, enum: [payg, comped, none] }
        balance_cents: { type: number, nullable: true, description: 'Prepaid balance; null unless billing_mode is payg.' }
        limits:
          type: object
          properties:
            requests_per_minute: { type: integer, nullable: true }
            reads_per_minute: { type: integer, nullable: true }
            max_concurrent_jobs: { type: integer, nullable: true }
            max_characters_per_request: { type: integer, nullable: true }
        daily:
          type: array
          items:
            type: object
            properties:
              day: { type: string, format: date }
              api_key_id: { type: integer, description: 'The api_ key the requests were made with; matches api_key.id on GET /v1/account. One row per key per day.' }
              requests: { type: integer }
              succeeded: { type: integer }
              characters: { type: integer }
              audio_seconds: { type: number }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              enum: [invalid_request_error, authentication_error, permission_error,
                     not_found_error, conflict_error, rate_limit_error, api_error]
            code: { type: string, description: Stable machine-readable code — branch on this. }
            message: { type: string }
            param: { type: string, nullable: true }
            next_step:
              type: string
              nullable: true
              enum: [request_access, mint_api_key, add_billing, top_up, reduce_text, retry_after]
              description: The remedy for this error, if there is one stable enough to name.
            resolution_url:
              type: string
              format: uri
              nullable: true
              description: An absolute URL a person can open to fix the problem named by next_step.
            max_characters: { type: number, nullable: true, description: 'text_too_long: the per-request cap.' }
            characters: { type: number, nullable: true, description: 'text_too_long: the length that was sent.' }
            balance_cents: { type: number, nullable: true, description: 'insufficient_balance: the current prepaid balance.' }
            cost_cents: { type: number, nullable: true, description: 'insufficient_balance: the price of the rejected request.' }
            request_id: { type: string }

    OpenAIError:
      type: object
      description: The error envelope POST /v1/audio/speech uses (OpenAI's own shape), nulls kept.
      properties:
        error:
          type: object
          properties:
            message: { type: string }
            type:
              type: string
              enum: [invalid_request_error, authentication_error, rate_limit_error, server_error]
            param: { type: string, nullable: true }
            code: { type: string, nullable: true }

  responses:
    BadRequest:
      description: Invalid request
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unauthorized:
      description: Missing/invalid/revoked/expired API key
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: Wrong key type, inaccessible voice, or SKU not enabled
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: Not found (cross-account resources also 404)
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: Idempotency conflict or job not complete
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Gone:
      description: Audio expired (30-day retention) or deleted
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unspeakable:
      description: No speakable content
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    RateLimited:
      description: Rate/quota/concurrency limit (see Retry-After)
      headers:
        Retry-After: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unavailable:
      description: Temporarily unavailable (see Retry-After)
      headers:
        Retry-After: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
