openapi: 3.1.0

info:
  title: iVox Public API
  version: '2026-09-10'
  summary: Синтез и обработка речи, звука и видео из вашего кода
  description: |
    Публичный API iVox — озвучка текста, диалоги и пакетная озвучка, звуковые эффекты и
    музыка, очистка голоса от фона, смена голоса, дубляж на другой язык, расшифровка
    речи и субтитры по готовому тексту. Тот же набор инструментов, что в веб-интерфейсе,
    с общим балансом.

    **База:** `https://ivoxstudio.ru/v1`. Только HTTPS, тела — JSON (UTF-8), загрузка
    файлов — `multipart/form-data`. Поля в snake_case.

    **Авторизация.** Ключ выпускается во вкладке «Разработчикам» личного кабинета и
    передаётся в заголовке `Authorization: Bearer ivox_live_…`. Ключ `ivox_test_…`
    работает в тестовом режиме: те же маршруты и валидация, без вызова провайдера и без
    списания, с файлами-образцами в результате.

    **Модель задач.** Инструменты асинхронные: `POST` отвечает 202 и объектом задачи в
    статусе `queued`. Статус читается на `GET /v1/jobs/{id}` (с long-poll через `wait`)
    или приходит вебхуком — событиями `job.succeeded`, `job.failed`, `job.canceled`.
    Готовые файлы отдаются подписанными ссылками, которые живут 24 часа и перевыпускаются
    при каждом чтении задачи. Повтор `POST` безопасен с заголовком `Idempotency-Key`.

    **Тарификация.** Единица одна — символ; баланс общий с веб-интерфейсом. Поминутные
    инструменты округляют длительность вверх до целой минуты, минимум — одна минута.

    **Общие заголовки.** Каждый ответ несёт `Ivox-Request-Id`, ответы на запросы с
    ключом — ещё и `X-RateLimit-*`. Любой метод может ответить 5xx в общем конверте
    ошибки (`api_error` / `provider_error`).

    **Чего пока нет:**
    - отдельного домена `api.ivoxstudio.ru` — API отвечает на `/v1` основного домена;
    - версий по дате: заголовок `Ivox-Version` не читается и не отдаётся, версия за
      ключом не закрепляется;
    - IP-allowlist у ключа;
    - событий `job.queued`, `job.processing`, `balance.low`, `key.revoked` —
      поддерживаются только три терминальных события;
    - лимита на число одновременно активных задач;
    - отмены задачи в `processing` — там 409;
    - писем о сбоящем адресе вебхука — его состояние видно в `GET /v1/webhooks` и в
      истории доставок;
    - тарифа на видео и говорящий аватар — до его введения оба метода отвечают 403
      `tool_unavailable`.

    Руководство, примеры и выпуск ключей — https://ivoxstudio.ru/developers
  x-status:
    base_url: https://ivoxstudio.ru/v1
    dedicated_domain: false
    operations: 33
    tools: 13
    live:
      - Выпуск и отзыв ключей в личном кабинете, скоупы, запись открывает чтение
      - Тестовый режим — сохранённые задачи, сразу succeeded, с файлами-образцами
      - Тринадцать инструментов на единой модели задачи
      - Единый статус, список и отмена задачи на /v1/jobs, включая озвучку в очереди
      - Подписанные ссылки на результат, 24 часа, перевыпуск на каждом чтении
      - Вебхуки — подписка, ротация секрета, повторы, история доставок, в том числе для тестовых задач
      - Лимитер по ключу и заголовки X-RateLimit-*
      - Идемпотентность POST по заголовку Idempotency-Key — одно пространство на все инструменты
      - Проверка ссылок на медиа при постановке и оплата по настоящей длительности до провайдера
    not_implemented:
      - api.ivoxstudio.ru — своего домена нет, API отвечает на /v1 основного
      - Ivox-Version — дата-версии и версия, закреплённая за ключом
      - IP-allowlist и дата истечения у ключа — при выпуске задаются имя, права и режим
      - События job.queued, job.processing, balance.low, key.revoked
      - Письма о сбоящем адресе вебхука
      - Лимит одновременно активных задач
      - Отмена задачи в processing — там 409
      - Тариф на видео и на говорящий аватар — до него оба метода отвечают 403 tool_unavailable
  contact:
    name: Поддержка iVox
    url: https://t.me/iVoxOfficialSupportBot
  termsOfService: https://ivoxstudio.ru/terms

servers:
  - url: https://ivoxstudio.ru/v1
    description: Рабочий сервер

security:
  - ApiKey: []

tags:
  - name: Озвучка
    description: Синтез речи, диалог, пакет, регенерация фрагмента.
  - name: Задачи
    description: Единый статус, список и отмена для всех инструментов.
  - name: Звук
    description: Эффекты, музыка, изоляция голоса, смена голоса, дубляж.
  - name: Распознавание
    description: Расшифровка и субтитры по готовому тексту.
  - name: Видео
  - name: Аватары
  - name: Голоса
  - name: Вебхуки
  - name: Аккаунт

paths:
  /tts:
    post:
      tags: [Озвучка]
      operationId: createSpeech
      summary: Поставить задачу озвучки
      description: |
        Синтез речи из текста. Списание — 1 символ за символ текста после `strip`.
        Теги эмоций (`[задумчиво]`) понимает только модель `ivox-expressive`.

        Статус читается на `GET /v1/tts/{job_id}` и на общем `GET /v1/jobs/{id}`.
        Ответ — объект `SpeechJob`: в нём нет полей `started_at`, `finished_at` и
        `request`, которые есть у общего `Job`.

        Несуществующий `voice_id` — 404 до списания. Повтор с тем же `Idempotency-Key`
        сверяет тело (`text`, `voice_id`, `model`, `settings`, `normalize`): другое тело
        или ключ, уже занятый другим инструментом, — 409 `idempotency_key_reuse`.

        Тестовый ключ: 202, но задача `job_test_tts_…` уже `succeeded`, в `result`
        настоящая подписанная ссылка на короткий mp3, `X-Ivox-Chars-Charged: 0`.
      security: [{ ApiKey: [tts:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SpeechRequest' }
      responses:
        '202': { $ref: '#/components/responses/SpeechJobAccepted' }
        '200': { $ref: '#/components/responses/SpeechIdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VoiceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /tts/{job_id}:
    get:
      tags: [Озвучка]
      operationId: getSpeechJob
      summary: Статус задачи озвучки
      description: |
        Объект задачи озвучки (`SpeechJob`). Статусы те же, что у общей задачи, включая
        `canceled`: задача, снятая через `POST /v1/jobs/{id}/cancel`, читается здесь
        с этим статусом. Каждый ответ отдаёт свежеподписанную ссылку на файл. Упавшая
        задача несёт `error.code: provider_failed` — тот же, что на `/v1/jobs/{id}`.

        Адрес видит только озвучку, поставленную через API. Озвучка, созданная в
        интерфейсе сайта, и самые ранние API-озвучки, поставленные до введения этой
        проверки, — 404.
      security: [{ ApiKey: [tts:read] }]
      parameters: [{ $ref: '#/components/parameters/JobId' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      responses:
        '200':
          description: Задача
          headers:
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content: { application/json: { schema: { $ref: '#/components/schemas/SpeechJob' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /dialogue:
    post:
      tags: [Озвучка]
      operationId: createDialogue
      summary: Сцена из реплик разными голосами
      description: |
        Модель задана жёстко (`ivox-dialogue`): склейка реплик опирается на механику,
        которой нет у `ivox-expressive`. Поле `model` не принимается, теги эмоций не
        работают. Цена — 1 символ за символ суммарного текста реплик. Неизвестный голос
        любой реплики — 404 до списания.
      security: [{ ApiKey: [tts:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DialogueRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VoiceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /batch:
    post:
      tags: [Озвучка]
      operationId: createBatch
      summary: Пакетная озвучка списка строк
      description: |
        Один голос на весь пакет. Цена — как у обычной озвучки, посимвольно по сумме
        строк: пакет это удобство, а не скидка. Результат — zip, `files[]` по файлу на
        текст и, если попросили, склеенный файл.

        Голос — только каталожный: личный — 422 `unsupported_voice`, неизвестный — 404.
        Каждый текст — до 2000 символов (422 `text_too_long`), сумма — до 50 000
        (422 `batch_too_large`). Всё до списания.
      security: [{ ApiKey: [tts:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BatchRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VoiceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /regen/fragments:
    get:
      tags: [Озвучка]
      operationId: listRegenFragments
      summary: Фрагменты готовой записи
      description: |
        Разбивка записи из библиотеки на куски, каждый из которых можно переозвучить.
        Работает только с нашими записями: у загруженного со стороны файла нет
        исходного текста, и заменять в нём нечего. `audio_id` — `result.library_id`
        задачи озвучки, диалога или пакета (UUID).

        Тестовый ключ к библиотеке не ходит: на любой `audio_id` отвечает фикстурой —
        запись «Тестовая запись», 9 с, три фрагмента `frg_1`…`frg_3`, `audio_url: null`.
      security: [{ ApiKey: [tts:read] }]
      parameters:
        - name: audio_id
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 36 }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Фрагменты записи
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/RegenFragmentList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /regen:
    post:
      tags: [Озвучка]
      operationId: createRegen
      summary: Переозвучить фрагмент записи
      description: |
        Цена — 1 символ за символ нового текста, минимум 1. Другой голос — только каталожный:
        личный — 422 `unsupported_voice`, неизвестный — 404; незнакомый фрагмент — 422
        `unknown_fragment`. Всё до списания. Тестовый ключ ничего не списывает
        (`X-Ivox-Chars-Charged: 0`).
      security: [{ ApiKey: [tts:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegenRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VoiceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /voices:
    get:
      tags: [Голоса]
      operationId: listVoices
      summary: Каталог голосов и личные голоса
      description: |
        Один список на оба вида: у каталожного голоса `id` числовой, у личного (клона) —
        идентификатор провайдера. Тот же `voice_id` принимают озвучка, диалог, пакет,
        регенерация и смена голоса.

        `preview_url` всегда на нашем домене: у каталожного голоса —
        `https://<хост>/api/v1/public/voices/<id>/preview`, у личного — тот же вход с
        подписью `?exp=&sig=` (24 часа) либо подписанная ссылка `/api/v1/files/…`, либо
        `null`. Фильтр `category` действует и на личные голоса; `language` и `gender`
        личные голоса из выдачи убирают — ни языка, ни пола у клона не хранится.
        Порядок — порядок каталога; страница режется только курсором.

        Клонирования и дизайна голоса в публичном API пока нет — только чтение.
      security: [{ ApiKey: [voices:read] }]
      parameters:
        - name: language
          in: query
          schema: { type: string, maxLength: 16 }
        - name: gender
          in: query
          schema: { type: string, enum: [male, female] }
        - name: category
          in: query
          schema: { type: string, maxLength: 64 }
        - name: q
          in: query
          description: Поиск по имени и описанию.
          schema: { type: string, maxLength: 120 }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/StartingAfter' }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Голоса
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/VoiceList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /sfx:
    post:
      tags: [Звук]
      operationId: createSfx
      summary: Звуковой эффект по описанию
      description: |
        Цена фиксированная за генерацию (1200 символов) и от длительности не зависит.
        Описание переводится на английский перед вызовом движка: кириллицу он принимает
        за речь и «озвучивает» её вместо генерации звука.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SfxRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /music:
    post:
      tags: [Звук]
      operationId: createMusic
      summary: Музыкальный трек по описанию
      description: |
        Цена — 25 символов за секунду, то есть 1500 за минуту. Списывается посекундная
        ставка, она же приходит в `usage.chars_estimated`.

        Полная цена трека списывается при постановке, как у остальных инструментов:
        `X-Ivox-Chars-Charged` в ответе на POST и `usage.chars_charged` равны цене, при
        нехватке — 402 сразу. Упавшая или отменённая задача возвращает списанное целиком.

        Две особенности учёта. Пока задача в `processing`, `usage.chars_refunded` уже
        равен цене; после успеха поле снова `0`. Возврат за упавший трек в
        `GET /v1/usage` учтён в `by_source` под `web`, а не `api`.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MusicRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /isolate:
    post:
      tags: [Звук]
      operationId: createIsolate
      summary: Очистить голос от фона
      description: |
        Источник — файл в поле `file` (multipart) либо ссылка `url` (JSON), ровно одно
        из двух. Файл до 500 МиБ, запись до 3600 с. Результат всегда mp3. Ссылка
        проверяется при постановке (см. `MediaSource`); запись по ссылке длиннее предела —
        задача `failed` с `audio_too_long` и полным возвратом.

        Цена — 1200 символов за минуту, длительность округляется ВВЕРХ до целой минуты,
        минимум — одна минута.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/IsolateForm' }
          application/json:
            schema: { $ref: '#/components/schemas/IsolateRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /change:
    post:
      tags: [Звук]
      operationId: createVoiceChange
      summary: Та же запись другим голосом
      description: |
        Speech-to-speech: играется ровно то, что было наговорено, с интонацией и паузами
        оригинала. Файл до 100 МиБ, запись до 300 с. Результат всегда mp3.

        `voice_id` проверяется при постановке: неизвестный голос или строка, которой
        голосом быть не может («²», больше 9 цифр), — 404 до списания.

        Адрес — `/v1/change` (не `/v1/voice-change`). Цена — 1200 символов за минуту с
        округлением вверх, минимум минута.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/ChangeForm' }
          application/json:
            schema: { $ref: '#/components/schemas/ChangeRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/VoiceNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /dub:
    post:
      tags: [Звук]
      operationId: createDub
      summary: Дубляж на другой язык
      description: |
        Файл до 2 ГиБ, запись до 10 800 с. Самая дорогая и самая долгая операция —
        вебхук здесь не роскошь. Цена — 5000 символов за минуту, округление вверх,
        минимум минута.

        Целевой язык лежит в поле `language` (не `target_language`).

        `format` меняет результат, `result.format` называет главный файл: `video` —
        `video_url` (mp4) и `audio_url` (mp3), `result.format: mp4`; `mp3` — только
        `audio_url`, `mp3`; `wav` — только `audio_url` (настоящий wav, PCM 16 бит,
        44,1 кГц), `wav`. При `mp3`/`wav` `video_url` — `null`. Видео по ссылке длиннее
        10 800 с — задача `failed` с `video_too_long` и полным возвратом.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/DubForm' }
          application/json:
            schema: { $ref: '#/components/schemas/DubRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /transcribe:
    post:
      tags: [Распознавание]
      operationId: createTranscribe
      summary: Расшифровать речь с разделением по спикерам
      description: |
        Файл до 1 ГиБ. Цена — 37 символов за минуту, округление вверх, минимум минута.
        Результат: текст, сегменты по спикерам, `speakers_count`, ссылки на txt, srt и
        vtt и `history_id` — запись в истории расшифровок сайта.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/TranscribeForm' }
          application/json:
            schema: { $ref: '#/components/schemas/TranscribeRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /align:
    post:
      tags: [Распознавание]
      operationId: createAlign
      summary: Субтитры по готовому тексту
      description: |
        Отличие от расшифровки: текст уже есть и меняться не должен — нужны только
        тайминги. Файл до 50 МиБ, текст до 512 КиБ. Метод всегда отдаёт все три файла
        (srt, vtt, txt): они собираются из одного набора реплик и не стоят ничего сверх.

        Цена — 37 символов за минуту, округление вверх, минимум минута.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/AlignForm' }
          application/json:
            schema: { $ref: '#/components/schemas/AlignRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /video:
    post:
      tags: [Видео]
      operationId: createVideo
      summary: Ролик по текстовому описанию
      description: |
        **Инструмент включается тарифом.** Пока тариф не введён, метод отвечает `403` с
        `code: tool_unavailable`. С тарифом символы списываются при постановке задачи,
        как у остальных платных методов: в ответе придут `X-Ivox-Chars-Charged` и
        `X-Ivox-Chars-Balance`, а при нехватке — `402`. В веб-интерфейсе инструмент
        доступен.

        Предел длительности — 12 секунд: длиннее не делает ни одна модель каталога.

        Полей `mode`, `model` и `avatar_id` нет: движок один, а говорящий аватар —
        отдельный инструмент, `POST /v1/avatars/talking-head`.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VideoRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /avatars:
    get:
      tags: [Аватары]
      operationId: listAvatars
      summary: Персонажи аккаунта
      security: [{ ApiKey: [audio:read] }]
      parameters:
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/StartingAfter' }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Персонажи
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/AvatarList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }
    post:
      tags: [Аватары]
      operationId: createAvatar
      summary: Загрузить персонажа
      description: |
        Фотография или короткое видео, только `multipart/form-data`. Загрузка ничего не
        списывает. Файл до 10 МиБ (413 сверх), форматы jpg/jpeg, png, webp, mp4, mov,
        m4v, webm (иначе 415); JSON на этом методе — тоже 415.

        Живых персонажей у аккаунта не больше 50: следующая загрузка — 409
        `too_many_avatars`, удаление освобождает место. Лимит запросов — ведро постановки
        задач (20 в минуту): загрузка пишет файл на диск.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/AvatarForm' }
      responses:
        '201':
          description: Персонаж создан
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Avatar' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /avatars/{avatar_id}:
    delete:
      tags: [Аватары]
      operationId: deleteAvatar
      summary: Удалить персонажа
      security: [{ ApiKey: [audio:write] }]
      parameters:
        - name: avatar_id
          in: path
          required: true
          schema: { type: string }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Удалён
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/AvatarDeleted' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /avatars/talking-head:
    post:
      tags: [Аватары]
      operationId: createTalkingHead
      summary: Говорящий аватар
      description: |
        **Инструмент включается тарифом.** Пока тариф не введён, метод отвечает `403` с
        `code: tool_unavailable`. С тарифом символы списываются при постановке задачи.

        Длина текста — 40..700 символов: это пределы движка, более короткое или более
        длинное аудио он не обрабатывает.

        `voice_id` — только каталожный. Не передан — голос каталога по умолчанию (своего
        голоса у персонажа нет). Личный голос и не-ASCII цифры — 422
        `unsupported_voice`; больше 9 цифр или несуществующий голос — 404.
      security: [{ ApiKey: [audio:write] }]
      parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TalkingHeadRequest' }
      responses:
        '202': { $ref: '#/components/responses/JobAccepted' }
        '200': { $ref: '#/components/responses/IdempotentReplay' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /jobs:
    get:
      tags: [Задачи]
      operationId: listJobs
      summary: Список задач
      description: |
        Курсорная пагинация, порядок — от новых к старым. Курсор ведёт только вперёд:
        параметра `ending_before` нет, в `starting_after` передаётся `next_cursor`
        предыдущего ответа.

        Список показывает только задачи тех инструментов, что открыты скоупами ключа, и
        только своего режима: тестовый ключ видит тестовые задачи, боевой — боевые.

        Озвучка — только поставленная через API (`POST /v1/tts`). Озвучки, сделанной в
        интерфейсе сайта, в списке нет; самые ранние API-озвучки, поставленные до
        введения этой проверки, тоже не показываются.

        `usage.chars_estimated` в списке равен `chars_charged`: оценку при постановке
        отдаёт только ответ на `POST`.
      security: [{ ApiKey: [] }]
      parameters:
        - name: type
          in: query
          description: Машинное имя инструмента — `tts`, `sfx`, `dub`, `music`…
          schema: { type: string, maxLength: 32 }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/JobStatus' }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/StartingAfter' }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Задачи
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/JobList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /jobs/{job_id}:
    get:
      tags: [Задачи]
      operationId: getJob
      summary: Статус задачи любого инструмента
      description: |
        Каждый ответ отдаёт свежеподписанные ссылки: подпись живёт сутки, и без
        перевыпуска файл через сутки стал бы недостижим.

        Право на задачу выводится из ЕЁ инструмента, а не из адреса: задача `sfx`
        читается по `audio:read`, задача `tts` — по `tts:read`. Скоуп на запись внутри
        своего семейства открывает и чтение.

        Задача тестового ключа (`job_test_…`) читается здесь же, озвучка тоже.
        Придуманный идентификатор с этой приставкой — 404.

        Задача озвучки видна здесь, только если поставлена через API: озвучка из
        интерфейса сайта (и самые ранние API-озвучки) — 404.
        `usage.chars_estimated` здесь равен `chars_charged` — оценку при постановке
        сохраняйте из ответа на `POST`.
      security: [{ ApiKey: [] }]
      parameters:
        - { $ref: '#/components/parameters/JobId' }
        - name: wait
          in: query
          description: |
            Long-poll: соединение держится до `wait` секунд и отвечает раньше, если
            статус стал терминальным. Сахар, не замена вебхуку.
          schema: { type: integer, minimum: 0, maximum: 30, default: 0 }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Задача
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Job' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /jobs/{job_id}/cancel:
    post:
      tags: [Задачи]
      operationId: cancelJob
      summary: Отменить задачу
      description: |
        Работает только в `queued`: списанное возвращается целиком, возврат виден в
        `usage.chars_refunded`. В `processing` отмены нет — 409 с кодом
        `job_not_cancelable`: генерация у провайдера уже оплачена, попытки остановить её
        не делается. Отмена уже терминальной задачи — тот же 409; задача тестового ключа
        всегда терминальная.

        Задача озвучки отменяется по тому же правилу: в `queued` — полный возврат,
        статус `canceled` и событие `job.canceled`; начатая — 409. Озвучка, сделанная в
        интерфейсе сайта, отсюда не видна — 404.
      security: [{ ApiKey: [] }]
      parameters: [{ $ref: '#/components/parameters/JobId' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      responses:
        '200':
          description: Задача после отмены
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Job' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /webhooks:
    get:
      tags: [Вебхуки]
      operationId: listWebhooks
      summary: Подписки аккаунта
      description: Подписки режима этого ключа, от старых к новым.
      security: [{ ApiKey: [account:read] }]
      parameters: [{ $ref: '#/components/parameters/RequestIdHeader' }]
      responses:
        '200':
          description: Подписки
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/WebhookList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }
    post:
      tags: [Вебхуки]
      operationId: createWebhook
      summary: Подписаться на события задач
      description: |
        Адрес — только `https`, без логина в URL, на публичный хост (иначе 422
        `invalid_url`). Имя проверяется резолвом при создании и при смене адреса: имя,
        которое не разрешается или ведёт во внутреннюю сеть, — тоже 422 `invalid_url`.
        Отзыв ключа выключает подписки, которые он завёл или которым последним сменил
        адрес (`status: disabled`, `disabled_reason` начинается с `key_revoked`).
        До 5 адресов на каждый режим: 5 боевым ключом и ещё 5 тестовым;
        шестой — 409 `too_many_webhook_endpoints`. Подписка получает события задач
        своего режима. Секрет (`whsec_…`) отдаётся один раз при создании — потом только
        ротация.

        События озвучки приходят только за задачи, поставленные через API, — не за
        озвучку в интерфейсе сайта.

        Скоуп — `account:write`. Скоупа `webhooks:manage` нет.
      security: [{ ApiKey: [account:write] }]
      parameters: [{ $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreateRequest' }
      responses:
        '201':
          description: Подписка создана; `secret` больше не покажется.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /webhooks/{webhook_id}:
    get:
      tags: [Вебхуки]
      operationId: getWebhook
      summary: Одна подписка
      security: [{ ApiKey: [account:read] }]
      parameters: [{ $ref: '#/components/parameters/WebhookId' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      responses:
        '200':
          description: Подписка
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }
    patch:
      tags: [Вебхуки]
      operationId: updateWebhook
      summary: Изменить адрес, набор событий или состояние
      description: |
        `status: enabled` включает обратно подписку, отключённую после 20 подряд
        исчерпанных доставок: успешный ответ сам её уже не вернёт.
      security: [{ ApiKey: [account:write] }]
      parameters: [{ $ref: '#/components/parameters/WebhookId' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookUpdateRequest' }
      responses:
        '200':
          description: Подписка после правки
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }
    delete:
      tags: [Вебхуки]
      operationId: deleteWebhook
      summary: Удалить подписку
      security: [{ ApiKey: [account:write] }]
      parameters: [{ $ref: '#/components/parameters/WebhookId' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      responses:
        '204':
          description: Удалена; тела нет.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /webhooks/{webhook_id}/rotate_secret:
    post:
      tags: [Вебхуки]
      operationId: rotateWebhookSecret
      summary: Сменить секрет подписи
      description: |
        Новый секрет отдаётся один раз в ответе. Прежний принимается ещё 24 часа — время
        на то, чтобы обновить секрет в своём сервисе. Всё это время в `Ivox-Signature` приходят две подписи `v1=` —
        новым секретом и старым; запрос наш, если совпала любая.
      security: [{ ApiKey: [account:write] }]
      parameters: [{ $ref: '#/components/parameters/WebhookId' }, { $ref: '#/components/parameters/RequestIdHeader' }]
      responses:
        '200':
          description: Подписка с новым `secret`
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /webhooks/{webhook_id}/deliveries:
    get:
      tags: [Вебхуки]
      operationId: listWebhookDeliveries
      summary: История доставок
      description: От новых к старым, с журналом попыток.
      security: [{ ApiKey: [account:read] }]
      parameters:
        - { $ref: '#/components/parameters/WebhookId' }
        - { $ref: '#/components/parameters/Limit' }
        - { $ref: '#/components/parameters/StartingAfter' }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Доставки
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/WebhookDeliveryList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /webhooks/{webhook_id}/deliveries/{delivery_id}/retry:
    post:
      tags: [Вебхуки]
      operationId: retryWebhookDelivery
      summary: Повторить доставку вручную
      description: |
        Доставка, которая прямо сейчас отправляется (`status: delivering`), — 409
        `delivery_in_progress`: иначе событие ушло бы дважды. Уже доставленная — 409
        `delivery_already_succeeded`: повторять нечего.
      security: [{ ApiKey: [account:write] }]
      parameters:
        - { $ref: '#/components/parameters/WebhookId' }
        - name: delivery_id
          in: path
          required: true
          schema: { type: string }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Доставка поставлена в очередь заново
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/WebhookDelivery' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /account:
    get:
      tags: [Аккаунт]
      operationId: getAccount
      summary: Баланс, тариф и состояние подписки
      description: |
        Тестовый ключ видит те же настоящие цифры, но с `livemode: false`.
      security: [{ ApiKey: [account:read] }]
      parameters: [{ $ref: '#/components/parameters/RequestIdHeader' }]
      responses:
        '200':
          description: Аккаунт
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Account' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /usage:
    get:
      tags: [Аккаунт]
      operationId: getUsage
      summary: Расход за период
      description: |
        Окно по умолчанию — 30 дней, максимум 366 (422 `range_too_wide`). Окно
        отсчитывается от заданного конца: только `to` — `from = to − 29 дней`; только
        `from` — от `from` по сегодня. `from` позже `to` — 422 `invalid_range`.
        `by_source` разделяет расход сайта и расход по API.
      security: [{ ApiKey: [account:read] }]
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
        - name: group_by
          in: query
          schema: { type: string, enum: [day, type], default: day }
        - { $ref: '#/components/parameters/RequestIdHeader' }
      responses:
        '200':
          description: Расход
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Usage' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

  /estimate:
    post:
      tags: [Аккаунт]
      operationId: estimateCost
      summary: Сколько будет стоить
      description: |
        Ничего не запускает и не списывает. **Пока считает только озвучку**: `type`
        принимает единственное значение `tts`, на остальное отвечает 422
        `unsupported_type`. Скоуп — `tts:read` (его открывает и `tts:write`).
      security: [{ ApiKey: [tts:read] }]
      parameters: [{ $ref: '#/components/parameters/RequestIdHeader' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EstimateRequest' }
      responses:
        '200':
          description: Оценка
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
            Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
          content: { application/json: { schema: { $ref: '#/components/schemas/Estimate' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/ProviderFailed' }
        '503': { $ref: '#/components/responses/ProviderUnavailable' }
        '504': { $ref: '#/components/responses/ProviderTimeout' }

webhooks:
  jobEvent:
    post:
      operationId: onJobEvent
      summary: Событие задачи (мы стучимся к вам)
      description: |
        Доставка at-least-once, порядок НЕ гарантирован. Обработчик обязан быть
        идемпотентным по `Ivox-Event-Id`.

        Подпись считается по СЫРОМУ телу: `v1 = HMAC-SHA256(secret, "{t}.{raw_body}")`,
        сравнивать константным по времени сравнением, отклонять `t` старше 5 минут. Во
        время нахлёста после ротации (24 часа) в заголовке приходят две подписи —
        `t=…,v1=<новый>,v1=<старый>`; подошла любая — значит запрос наш. Не разбирайте
        заголовок в словарь: он оставит только последнее `v1=`.

        `created_at` события — момент завершения задачи, одинаковый во всех попытках
        доставки. Доставка подключается ровно к проверенному публичному IP (SNI и `Host`
        — имя из адреса), переадресации не отслеживаются.
      parameters:
        - name: Ivox-Signature
          in: header
          required: true
          schema: { type: string, examples: ['t=1789012831,v1=5f2c9a…'] }
        - name: Ivox-Event-Id
          in: header
          required: true
          schema: { type: string }
        - name: Ivox-Event-Type
          in: header
          required: true
          schema: { $ref: '#/components/schemas/WebhookEventType' }
        - name: Ivox-Delivery-Attempt
          in: header
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200':
          description: |
            Любой 2xx считается успехом. Срок попытки — 10 секунд на всё сразу: DNS,
            соединение и ответ. Иначе 8 повторов с экспонентой: 10 с, 30 с, 2 мин, 10 мин,
            30 мин, 2 ч, 6 ч, 24 ч — цепочка около 33 часов. Исчерпанная доставка помечает
            подписку `failing`, 20 исчерпанных подряд — `disabled`.

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      bearerFormat: ivox_live_… | ivox_test_…
      description: |
        Ключ только в заголовке: в query он попадает в логи и Referer. CORS выключен —
        публичный API не предназначен для вызова из браузера напрямую.

        Тестовый ключ (`ivox_test_`) ходит по тем же маршрутам и проходит ту же
        валидацию, но провайдера не зовёт и ничего не списывает. Каждый POST сохраняет
        настоящую задачу `job_test_<инструмент>_<12 hex>` с `livemode: false`, сразу
        `succeeded`; все `*_url` в результате — подписанные ссылки на короткие
        файлы-образцы (mp3, mp4, jpg, srt, vtt, txt, zip). Образец — не файл в
        запрошенном формате: дубляж с `format: wav` и диалог называют `result.format:
        wav`, а по ссылке лежит mp3. Задача читается на
        `/v1/jobs/{id}` и `/v1/tts/{id}`, видна в списке, отменить её нельзя (409),
        идемпотентность работает как в бою (повтор — 200), подписки тестового режима получают
        `job.succeeded`. Тестовый ключ видит только тестовые задачи и подписки.

        Запись открывает чтение на любом маршруте: `X:write` включает `X:read`.

        Сообщения 401 по-русски: «Нужен ключ API в заголовке «Authorization: Bearer
        <ключ>».» (`missing_api_key`), «Ключ API не принят: такого ключа нет.»
        (`invalid_api_key`), «Ключ API отозван.» (`revoked_api_key`; у удалённого
        аккаунта — «Ключ API отозван: аккаунт удалён.»), «Срок действия ключа API
        истёк.» (`expired_api_key`). Удаление аккаунта отзывает все его ключи.

        IP-allowlist у ключа не реализован — не закладывайтесь на него.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Повтор с тем же ключом и тем же телом вернёт первую задачу с кодом 200 вместо
        202 и с `X-Ivox-Chars-Charged: 0` — второго списания нет. Тот же ключ с другим
        телом — 409 `idempotency_key_reuse`; у `/tts` сверяются `text`, `voice_id`,
        `model`, `settings` и `normalize`.

        Пространство ключей одно на все инструменты, включая `/tts`: ключ, занятый
        задачей одного инструмента, на другом — тот же 409. У тестового и боевого ключа
        пространства раздельные. Ключ длиннее 255 символов — 422 на любом методе.

        Срока годности у ключа нет: он действует столько же, сколько хранится сама задача.
      schema: { type: string, minLength: 1, maxLength: 255 }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    StartingAfter:
      name: starting_after
      in: query
      description: |
        Значение `next_cursor` из предыдущего ответа — непрозрачная строка (base64url), а
        НЕ идентификатор объекта. Голый id или испорченный курсор (в том числе не-base64,
        например «жж») — 422 `invalid_cursor`; курсор удалённого объекта — тот же 422,
        список начинают заново. Курсор ведёт только вперёд.
      schema: { type: string, maxLength: 256 }
    RequestIdHeader:
      name: Ivox-Request-Id
      in: header
      description: |
        Свой идентификатор запроса. Если он подходит под `[A-Za-z0-9_.:-]{1,64}`, мы
        вернём его в ответном `Ivox-Request-Id` и в `error.request_id` и запишем в логи;
        иначе выдадим свой `req_…`.
      schema: { type: string, pattern: '^[A-Za-z0-9_.:-]{1,64}$' }
    JobId:
      name: job_id
      in: path
      required: true
      schema: { type: string }
    WebhookId:
      name: webhook_id
      in: path
      required: true
      schema: { type: string }

  headers:
    CharsCharged:
      description: |
        Фактически списано символов этим запросом. У музыки здесь полная цена трека —
        она списывается при постановке, как у всех. У файла по ссылке — одна минута:
        остаток добирается до вызова провайдера и в заголовок этого ответа не попадает.
        Ноль — у тестового ключа и у идемпотентного повтора (200).
      schema: { type: integer }
    CharsBalance:
      description: Остаток символов после операции
      schema: { type: integer }
    RateLimitLimit:
      description: |
        Предел ведра за минуту: 120 у чтения и управления, 20 у постановки задач. Лимит —
        на ключ. Заголовки `X-RateLimit-*` приходят на любой ответ запроса с ключом в
        разбираемой форме (включая 401 на чужой ключ, 413, 415, 5xx); на 401
        `missing_api_key` их нет. Квоту не расходуют только 415 и 413 по заголовку
        `Content-Length` — они отдаются до проверки лимита; 413, пойманный при чтении
        тела, и 413 на файл больше предела инструмента засчитываются. Окно — взвешенный счётчик
        двух соседних минут, на стыке возможен проход сверх предела.
      schema: { type: integer }
    RateLimitRemaining:
      description: Сколько запросов осталось в текущем окне
      schema: { type: integer }
    RateLimitReset:
      description: |
        Unix-время. На обычном ответе — конец текущей минуты. На 429 — момент, когда
        можно повторять: сейчас + `Retry-After`.
      schema: { type: integer }
    RetryAfter:
      description: Через сколько секунд повторять
      schema: { type: integer }
    RequestId:
      description: |
        Идентификатор запроса; называйте его в поддержке. Приходит на каждом ответе. Если
        клиент прислал свой `Ivox-Request-Id` в формате `[A-Za-z0-9_.:-]{1,64}`, здесь он
        же, иначе — наш `req_…`.
      schema: { type: string }
    WwwAuthenticate:
      description: 'Bearer realm="ivox"'
      schema: { type: string }

  responses:
    JobAccepted:
      description: |
        Задача принята, статус `queued`. У тестового ключа тот же 202, но задача уже
        `succeeded` с заполненным `result` и `X-Ivox-Chars-Charged: 0`.
      headers:
        X-Ivox-Chars-Charged: { $ref: '#/components/headers/CharsCharged' }
        X-Ivox-Chars-Balance: { $ref: '#/components/headers/CharsBalance' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Job' } } }
    IdempotentReplay:
      description: |
        Повтор с тем же `Idempotency-Key` и тем же телом — прежняя задача, без второго
        списания (`X-Ivox-Chars-Charged: 0`). Код 200 на любом инструменте, в том числе
        у тестового ключа.
      headers:
        X-Ivox-Chars-Charged: { $ref: '#/components/headers/CharsCharged' }
        X-Ivox-Chars-Balance: { $ref: '#/components/headers/CharsBalance' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Job' } } }
    SpeechJobAccepted:
      description: |
        Задача озвучки принята, статус `queued`. У тестового ключа тот же 202, но задача
        `job_test_tts_…` уже `succeeded` со ссылкой на файл-образец.
      headers:
        X-Ivox-Chars-Charged: { $ref: '#/components/headers/CharsCharged' }
        X-Ivox-Chars-Balance: { $ref: '#/components/headers/CharsBalance' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/SpeechJob' } } }
    SpeechIdempotentReplay:
      description: |
        Повтор с тем же `Idempotency-Key` и тем же телом — прежняя задача озвучки, без
        второго списания (`X-Ivox-Chars-Charged: 0`). Код 200, в том числе у тестового
        ключа.
      headers:
        X-Ivox-Chars-Charged: { $ref: '#/components/headers/CharsCharged' }
        X-Ivox-Chars-Balance: { $ref: '#/components/headers/CharsBalance' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/SpeechJob' } } }
    BadRequest:
      description: Тело не разобралось как JSON (`invalid_request_error`, `invalid_json`)
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unauthorized:
      description: |
        Ключа нет, он не наш, отозван (в том числе удалением аккаунта) или истёк
        (`authentication_error`; `missing_api_key`, `invalid_api_key`,
        `revoked_api_key`, `expired_api_key`). Сообщение по-русски. `X-RateLimit-*`
        приходят, если ключ в заголовке есть; на `missing_api_key` — нет.
      headers:
        WWW-Authenticate: { $ref: '#/components/headers/WwwAuthenticate' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: |
        `permission_error`. Чаще всего `code: insufficient_scope` — у ключа нет нужного
        скоупа, сообщение «У ключа нет нужных прав: <скоупы>.»; `type` и `code` — два
        разных поля, а не два класса ошибки. У видео и говорящего аватара то же 403 с
        `code: tool_unavailable`: инструмент выключен, пока не задан тариф.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: |
        Объекта нет или он чужой (`not_found_error`). Чужой объект намеренно неотличим
        от несуществующего — иначе API становится оракулом для перебора идентификаторов.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    VoiceNotFound:
      description: |
        Голос не найден (`not_found_error`, `not_found`) — до списания. Берите `voice_id`
        из `GET /v1/voices`. У `/regen` тем же 404 отвечает и чужая или несуществующая
        запись `audio_id`.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: |
        Класс — `invalid_request_error`, `code` различает случай:
        `idempotency_key_reuse` (тот же ключ с другим телом или на другом инструменте,
        включая `/tts`), `job_not_cancelable` (отмена не в `queued`),
        `delivery_in_progress` (повтор доставки, которая отправляется),
        `delivery_already_succeeded` (повтор доставки, которая уже доставлена),
        `too_many_webhook_endpoints` (больше 5 подписок в режиме).
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    TooLarge:
      description: |
        `invalid_request_error`, `payload_too_large`: JSON-тело больше 1 МиБ (на любом
        методе) или multipart-файл больше предела инструмента.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    UnsupportedMediaType:
      description: |
        `invalid_request_error`, `unsupported_media_type`: тело с `Content-Type` не
        `application/json` (и не `multipart/form-data` там, где принимается файл). Тело
        без `Content-Type` читается как JSON; `/cancel`, `/rotate_secret`, `/retry` и
        запросы без тела не проверяются.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    InsufficientCredits:
      description: |
        Не хватает кредитов API. Класс и код — `insufficient_credits`. Кредиты API —
        отдельный от символов сайта баланс; пополняется пакетом в разделе «Разработчикам».
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unprocessable:
      description: |
        Тело или параметры не прошли валидацию (`invalid_request_error`): `invalid_value`,
        `missing_field`, `invalid_url`, `invalid_cursor`, `text_too_long`,
        `batch_too_large`, `unsupported_voice` и др.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    RateLimited:
      description: |
        `rate_limit_error`. Код `rate_limited` — превышен лимит запросов на ключ (есть
        `Retry-After`). Код `too_many_active_jobs` — у аккаунта уже столько задач в работе,
        сколько разрешает пакет (Free 1, Starter 3, Pro 10, Business 30): дождитесь
        завершения любой. Код `test_quota_exceeded` — исчерпана месячная квота тестовых
        запросов пакета.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    InternalError:
      description: |
        Наша поломка (`api_error`, `internal_error`). Текст маскируется. Повторять с
        backoff.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    ProviderFailed:
      description: Провайдер отказал (`provider_error`, `provider_failed`). Повторять с backoff.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    ProviderUnavailable:
      description: |
        Провайдер недоступен (`provider_error`, `provider_unavailable` или
        `provider_circuit_open`). Повторять с backoff.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    ProviderTimeout:
      description: Провайдер не ответил вовремя (`provider_error`, `provider_timeout`). Повторять с backoff.
      headers:
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
        Ivox-Request-Id: { $ref: '#/components/headers/RequestId' }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  schemas:
    ErrorType:
      type: string
      description: |
        Класс ошибки — на него можно писать логику. Набор закрыт. Класс нехватки
        символов называется `insufficient_credits` (не `insufficient_balance_error`),
        класс «объекта нет» — `not_found_error` (не `invalid_request_error`).
      enum:
        - authentication_error
        - permission_error
        - invalid_request_error
        - not_found_error
        - rate_limit_error
        - insufficient_credits
        - provider_error
        - api_error

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type: { $ref: '#/components/schemas/ErrorType' }
            code:
              type: string
              description: Конкретная причина внутри класса.
              examples: [insufficient_scope, idempotency_key_reuse, text_too_long, unsupported_media_type, invalid_cursor, missing_field, tool_unavailable]
            message:
              type: string
              description: Текст на русском — для лога и для человека, не для парсинга.
            param:
              anyOf: [{ type: string }, { type: 'null' }]
            doc_url: { type: string }
            request_id:
              type: string
              description: То же значение, что в заголовке `Ivox-Request-Id`.
              examples: ['req_5k7j1PLV6bRFQ1Me3iuhtj']

    JobStatus:
      type: string
      description: Терминальные — `succeeded`, `failed`, `canceled`; на них поллинг обязан останавливаться.
      enum: [queued, processing, succeeded, failed, canceled]

    JobUsage:
      type: object
      required: [chars_estimated]
      properties:
        chars_estimated:
          type: integer
          description: |
            В ответе на POST — оценка при постановке (у файла по ссылке — цена одной
            минуты). На `GET /v1/jobs/{id}`, в списке и в теле вебхука оценка не
            хранится, и здесь то же, что `chars_charged`. У озвучки на `/v1/tts/{id}` —
            длина текста.
        chars_charged:
          anyOf: [{ type: integer }, { type: 'null' }]
        chars_refunded: { type: integer, default: 0 }

    JobError:
      type: object
      description: |
        Ошибка внутри упавшей задачи — не HTTP-ответ. Во всех случаях списанное
        возвращено. `insufficient_credits` — файл по ссылке оказался длиннее, а добрать
        цену не из чего (провайдер не вызывался); `audio_too_long`, `video_too_long`,
        `payload_too_large` — файл по ссылке нарушил предел инструмента; `job_failed` —
        прочий отказ (например, по ссылке не медиафайл).
      required: [type, code, message]
      properties:
        type: { $ref: '#/components/schemas/ErrorType' }
        code:
          type: string
          examples: [provider_failed, insufficient_credits, audio_too_long, video_too_long, payload_too_large, worker_lost, job_failed]
        message: { type: string }

    Job:
      type: object
      description: |
        Единый объект задачи. Поля `expires_at` нет: у самой задачи срока жизни нет,
        сутки живёт только подписанная ссылка внутри `result`.
      required: [id, object, type, status, livemode, created_at, updated_at, usage]
      properties:
        id:
          type: string
          description: Непрозрачная строка. У боевой задачи — UUID, у тестовой — `job_test_<инструмент>_<12 hex>`.
          examples: ['9f2b41c8-3d5e-4a17-8c60-2e7b1d904f33', 'job_test_sfx_4f1c09ab22de']
        object: { type: string, const: job }
        type:
          type: string
          description: |
            Машинное имя инструмента. Строка, а не перечисление: инструменты добавляются,
            и клиент обязан переживать незнакомое значение.
          examples: [tts, dialogue, batch, regen, sfx, music, isolate, change, dub, transcribe, align, video, avatars]
        status: { $ref: '#/components/schemas/JobStatus' }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        started_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
        finished_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
        request:
          anyOf: [{ type: object, additionalProperties: true }, { type: 'null' }]
          description: |
            Сохранённые параметры задачи — близко к телу запроса, но не байт в байт. У
            файловых инструментов добавлены служебные поля: `file_name`, `upload_id`,
            `upload_sha256`, замеренная `duration_sec` (дробная), у дубляжа —
            `upload_name`; `url: null` при загрузке файлом. У смены голоса есть
            `voice_name`. У пакета `voice_id` — число, а не строка. У озвучки `request`
            содержит `text`, `voice_id`, фактическую `model`, ползунки и `normalize`.
            Для логики опирайтесь на `result`.
        usage: { $ref: '#/components/schemas/JobUsage' }
        result:
          anyOf: [{ $ref: '#/components/schemas/JobResult' }, { type: 'null' }]
        error:
          anyOf: [{ $ref: '#/components/schemas/JobError' }, { type: 'null' }]

    JobResult:
      type: object
      additionalProperties: true
      description: |
        Форма результата у каждого инструмента своя, поэтому объект открытый. Ниже —
        ключи, которые встречаются; конкретный инструмент отдаёт своё подмножество.

        Все `*_url` и `files[].url` — подписанные ссылки вида
        `https://<хост>/api/v1/files/v2.<токен>.<расширение>`: срок 24 часа, перевыпуск
        на каждом чтении задачи. Токен непрозрачный — разбирать его не нужно, параметров
        `?exp=&sig=` в ссылке нет. Ссылки прежнего формата действуют до истечения срока.
      properties:
        audio_url:
          anyOf: [{ type: string }, { type: 'null' }]
        video_url:
          anyOf: [{ type: string }, { type: 'null' }]
          description: 'Видео и дубляж с `format: video` (mp4). У дубляжа в mp3/wav — `null`.'
        poster_url:
          anyOf: [{ type: string }, { type: 'null' }]
          description: Кадр-обложка ролика (видео, говорящий аватар).
        source_url:
          anyOf: [{ type: string }, { type: 'null' }]
          description: Исходник «до» — у изоляции, смены голоса, расшифровки и субтитров (`align`).
        subtitles_url:
          anyOf: [{ type: string }, { type: 'null' }]
        srt_url:
          anyOf: [{ type: string }, { type: 'null' }]
        vtt_url:
          anyOf: [{ type: string }, { type: 'null' }]
        txt_url:
          anyOf: [{ type: string }, { type: 'null' }]
        zip_url:
          anyOf: [{ type: string }, { type: 'null' }]
          description: Пакетная озвучка — архив со всеми файлами.
        combined_url:
          anyOf: [{ type: string }, { type: 'null' }]
        full_url:
          anyOf: [{ type: string }, { type: 'null' }]
        fragment_url:
          anyOf: [{ type: string }, { type: 'null' }]
        audio_expires_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
          description: Когда истечёт подпись ссылок в этом ответе. Перечитайте задачу — получите новую.
        duration_sec:
          anyOf: [{ type: number }, { type: 'null' }]
          description: |
            Секунды, всегда целое (округлено) — у звукового эффекта тоже, хотя в запросе
            его `duration_sec` бывает дробным. Дробными приходят только таймкоды в
            массивах: `lines[]` диалога, `cues[]` субтитров, `segments[].start_sec`.
        format:
          anyOf: [{ type: string }, { type: 'null' }]
          description: |
            Формат главного файла: `mp3`, `wav`, `mp4`… У дубляжа — `mp4` при
            `format: video`, `mp3` и `wav` соответственно, а не эхо запроса. У задачи
            тестового ключа поле называет формат боевого ответа, а файл-образец по
            ссылке всегда mp3 (mp4 у видео): дубляж с `format: wav` и диалог отвечают
            `wav`, но скачается mp3.
        model:
          anyOf: [{ type: string }, { type: 'null' }]
          description: |
            Наше имя модели. Незнакомый движок наружу не отдаётся — ключ остаётся со
            значением `null`.
        language:
          anyOf: [{ type: string }, { type: 'null' }]
        text:
          anyOf: [{ type: string }, { type: 'null' }]
        speakers_count:
          anyOf: [{ type: integer }, { type: 'null' }]
          description: Число голосов — у расшифровки и дубляжа.
        history_id:
          anyOf: [{ type: string }, { type: 'null' }]
          description: Запись в истории инструмента на сайте — у расшифровки и у субтитров (`align`).
        segments:
          type: array
          items: { $ref: '#/components/schemas/TranscriptSegment' }
        cues:
          type: array
          description: Реплики субтитров с таймингами — у `align`.
          items: { type: object, additionalProperties: true }
        files:
          type: array
          description: |
            По одному файлу на единицу работы: у пакетной озвучки — на текст, у диалога —
            на реплику (связь с `lines[]` по `index`). У задачи тестового ключа `url` — тоже
            настоящая ссылка на файл-образец; исключение — диалог в песочнице: там `files[]`
            нет вовсе, только нулевые `lines[]`. Ссылка внутри перевыпускается на
            каждом чтении задачи так же, как ключи `*_url`, и `expires_at` элемента
            обновляется вместе с ней.
          items: { type: object, additionalProperties: true }
        lines:
          type: array
          description: |
            Тайминги реплик диалога числами (`index`, `start_sec`, `duration_sec`) — по ним
            сцена режется на стороне клиента. Сами файлы реплик лежат в `files[]`.
          items: { type: object, additionalProperties: true }
        library_id:
          anyOf: [{ type: string }, { type: 'null' }]
          description: Запись в библиотеке — UUID (не `aud_…`). Её же принимает `/regen` как `audio_id`.
        share_id:
          anyOf: [{ type: string }, { type: 'null' }]

    TranscriptSegment:
      type: object
      properties:
        speaker:
          anyOf: [{ type: string }, { type: 'null' }]
        speaker_index:
          anyOf: [{ type: integer }, { type: 'null' }]
        start_sec:
          anyOf: [{ type: number }, { type: 'null' }]
        text: { type: string }

    JobList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/Job' }
        has_more: { type: boolean, default: false }
        next_cursor:
          anyOf: [{ type: string }, { type: 'null' }]

    SpeechJob:
      type: object
      description: |
        Объект задачи озвучки на её собственном адресе. Отличия от общего `Job`:
        `type` всегда `tts`, `result` описан точно, полей `started_at`, `finished_at` и
        `request` нет. Набор статусов тот же, включая `canceled`: задача, снятая через
        `POST /v1/jobs/{id}/cancel`, читается с этим статусом и здесь.
      required: [id, object, type, status, livemode, created_at, updated_at, usage]
      properties:
        id:
          type: string
          description: UUID у боевой задачи, `job_test_tts_<12 hex>` у тестовой.
        object: { type: string, const: job }
        type: { type: string, const: tts }
        status: { type: string, enum: [queued, processing, succeeded, failed, canceled] }
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        usage: { $ref: '#/components/schemas/JobUsage' }
        result:
          anyOf: [{ $ref: '#/components/schemas/SpeechResult' }, { type: 'null' }]
        error:
          anyOf: [{ $ref: '#/components/schemas/JobError' }, { type: 'null' }]

    SpeechResult:
      type: object
      properties:
        audio_url:
          anyOf: [{ type: string }, { type: 'null' }]
        audio_expires_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
          description: |
            Когда истечёт подпись ссылки в этом ответе. Перечитайте задачу — получите новую.
        duration_sec:
          anyOf: [{ type: integer }, { type: 'null' }]
        format:
          anyOf: [{ type: string }, { type: 'null' }]
          description: Формат файла — `mp3`.
        model:
          anyOf: [{ type: string }, { type: 'null' }]
        library_id:
          anyOf: [{ type: string }, { type: 'null' }]
          description: UUID записи в библиотеке (не `aud_…`).
        share_id:
          anyOf: [{ type: string }, { type: 'null' }]

    SpeechSettings:
      type: object
      description: |
        Ползунки интерфейса, 0..100. Учтите: `ivox-expressive` принимает из них только
        `stability` — на ней движение остальных трёх ничего не меняет.
      properties:
        speed: { type: integer, minimum: 0, maximum: 100, default: 65 }
        stability: { type: integer, minimum: 0, maximum: 100, default: 50 }
        similarity: { type: integer, minimum: 0, maximum: 100, default: 75 }
        style: { type: integer, minimum: 0, maximum: 100, default: 10 }

    SpeechRequest:
      type: object
      description: |
        Полей `dictionary`, `metadata` и `project_id` нет: словарь произношений
        применяется всегда, произвольных меток задача не хранит, а проект задаётся у
        записи в библиотеке, а не у задачи.
      required: [text, voice_id]
      additionalProperties: false
      properties:
        text: { type: string, minLength: 1, maxLength: 5000 }
        voice_id:
          type: string
          minLength: 1
          maxLength: 128
          description: Из `GET /v1/voices`. Голоса по умолчанию нет — поле обязательно. Несуществующий — 404.
        model:
          anyOf: [{ type: string, maxLength: 64 }, { type: 'null' }]
          description: |
            `ivox-expressive` (по умолчанию), `ivox-multilingual`, `ivox-fast`.
            Незнакомое значение — не ошибка: подставим поддерживаемую и вернём
            фактическую в `result.model`. Провайдерские имена (`eleven_v3`) синонимами
            не считаются.
        format:
          anyOf: [{ type: string, maxLength: 32 }, { type: 'null' }]
          description: |
            Поддерживается только `mp3_44100_128` (по умолчанию); прочее — 422
            `unsupported_format`.
        settings: { $ref: '#/components/schemas/SpeechSettings' }
        normalize:
          type: boolean
          default: false
          description: Нормализация чисел и сокращений. Здесь по умолчанию выключена — ради предсказуемости.

    DialogueLine:
      type: object
      required: [text, voice_id]
      additionalProperties: false
      properties:
        text: { type: string, minLength: 1, maxLength: 5000 }
        voice_id: { type: string, minLength: 1, maxLength: 128 }

    DialogueRequest:
      type: object
      required: [lines]
      additionalProperties: false
      properties:
        lines:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/DialogueLine' }

    BatchRequest:
      type: object
      required: [texts, voice_id]
      additionalProperties: false
      properties:
        texts:
          type: array
          minItems: 1
          maxItems: 100
          description: Сумма символов — не больше 50 000 (422 `batch_too_large`).
          items: { type: string, maxLength: 2000 }
        voice_id:
          type: string
          minLength: 1
          maxLength: 128
          description: Только каталожный (числовой); личный — 422 `unsupported_voice`.
        format: { type: string, const: mp3, default: mp3 }
        naming: { type: string, enum: [index, phrase], default: index }
        combined:
          type: boolean
          default: false
          description: Дополнительно склеить всё в один файл.

    RegenRequest:
      type: object
      required: [audio_id, fragment_id, text]
      additionalProperties: false
      properties:
        audio_id:
          type: string
          minLength: 1
          maxLength: 36
          description: '`result.library_id` задачи озвучки, диалога или пакета (UUID).' 
        fragment_id:
          type: string
          minLength: 1
          maxLength: 32
          description: Из `GET /v1/regen/fragments`.
        text: { type: string, minLength: 1, maxLength: 2000 }
        voice_id:
          anyOf: [{ type: string, maxLength: 128 }, { type: 'null' }]
          description: По умолчанию — голос записи. Только каталожный; личный — 422 `unsupported_voice`.
        style: { type: string, enum: [original, calm, energetic], default: original }
        output: { type: string, enum: [full, fragment], default: full }

    RegenSource:
      type: object
      required: [audio_id]
      properties:
        audio_id: { type: string }
        name:
          anyOf: [{ type: string }, { type: 'null' }]
        duration_sec:
          anyOf: [{ type: integer }, { type: 'null' }]
        voice:
          anyOf: [{ type: string }, { type: 'null' }]
        audio_url:
          anyOf: [{ type: string }, { type: 'null' }]
        audio_expires_at:
          anyOf: [{ type: string }, { type: 'null' }]

    RegenFragment:
      type: object
      required: [id, start_sec, end_sec, text]
      properties:
        id: { type: string }
        start_sec: { type: integer }
        end_sec: { type: integer }
        text: { type: string }

    RegenFragmentList:
      type: object
      required: [object, source, data]
      properties:
        object: { type: string, const: list }
        source: { $ref: '#/components/schemas/RegenSource' }
        data:
          type: array
          items: { $ref: '#/components/schemas/RegenFragment' }
        has_more: { type: boolean, default: false }

    Voice:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
        description:
          anyOf: [{ type: string }, { type: 'null' }]
        gender:
          anyOf: [{ type: string, enum: [male, female] }, { type: 'null' }]
          description: '`null` — пол у голоса не указан (у личных голосов всегда).'
        category:
          anyOf: [{ type: string }, { type: 'null' }]
        preview_url:
          anyOf: [{ type: string }, { type: 'null' }]
          description: |
            Всегда наш домен. Каталог — `https://<хост>/api/v1/public/voices/<id>/preview`;
            личный голос — тот же вход с `?exp=&sig=` (24 часа) либо подписанная ссылка
            `/api/v1/files/…`; `null`, если прослушки нет.
        language_codes:
          anyOf: [{ type: array, items: { type: string } }, { type: 'null' }]
        personal: { type: boolean, default: false }

    VoiceList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/Voice' }
        has_more: { type: boolean, default: false }
        next_cursor:
          anyOf: [{ type: string }, { type: 'null' }]

    SfxRequest:
      type: object
      required: [prompt]
      additionalProperties: false
      properties:
        prompt: { type: string, minLength: 1, maxLength: 1000 }
        loop: { type: boolean, default: false }
        duration_sec:
          anyOf: [{ type: number, minimum: 0.5, maximum: 30 }, { type: 'null' }]
          description: |
            Границы 0.5..30 — движка, а не наши. Дробное значение допустимо, но
            `result.duration_sec` приходит округлённым до целых секунд. Не передан — длину
            выберет движок.

    MusicRequest:
      type: object
      required: [prompt]
      additionalProperties: false
      properties:
        prompt: { type: string, minLength: 1, maxLength: 2000 }
        genre: { type: string, maxLength: 120, default: '' }
        vocal: { type: string, enum: [instrumental, vocal], default: instrumental }
        length_sec: { type: integer, minimum: 3, maximum: 300, default: 30 }

    MediaSource:
      type: object
      description: |
        Общая часть инструментов, которые жуют готовое медиа: либо файл в multipart,
        либо ссылка в JSON — ровно одно из двух, иначе 422. Ссылка только **https**;
        ссылка по http отклоняется.

        Ссылка проверяется при постановке, до списания: не https, логин/пароль в адресе,
        непубличный хост (частные сети, loopback, link-local, CGNAT, IPv4 в IPv6, NAT64,
        прочие неглобальные диапазоны) — 422 `invalid_url`, `param: url`.

        Файл скачиваем мы сами у всех пяти инструментов: имя резолвится один раз,
        соединение идёт к проверенному IP, у скачивания общий срок
        `max(120 с, предел / 2 МиБ/с)`. При постановке списывается одна минута; измерив
        файл, сервис до вызова провайдера списывает остаток. Не из чего — задача `failed`
        с `insufficient_credits`; длиннее предела — `audio_too_long` / `video_too_long`;
        больше предела — `payload_too_large`; не медиа — `job_failed`. Во всех случаях
        списанное возвращается целиком, провайдер не вызывается.
      properties:
        url:
          anyOf: [{ type: string, maxLength: 2048 }, { type: 'null' }]

    IsolateRequest:
      allOf:
        - { $ref: '#/components/schemas/MediaSource' }
        - type: object
          description: |
            Полей `targets` и `format` нет: выборочного подавления у движка не
            существует, а результат всегда mp3.
          properties:
            strength:
              type: string
              enum: [strong, medium, soft]
              default: medium
              description: |
                Движок всегда отдаёт полностью изолированный голос; `medium` и `soft`
                подмешивают под него оригинал, то есть оставляют часть фона.

    IsolateForm:
      allOf:
        - { $ref: '#/components/schemas/IsolateRequest' }
        - type: object
          properties:
            file: { type: string, format: binary }

    ChangeRequest:
      allOf:
        - { $ref: '#/components/schemas/MediaSource' }
        - type: object
          required: [voice_id]
          description: |
            Полей `keep_tone`, `similarity` и `format` нет: результат всегда mp3.
          properties:
            voice_id:
              type: string
              minLength: 1
              maxLength: 128
              description: Проверяется при постановке — неизвестный или невозможный («²», больше 9 цифр) — 404 до списания.
            remove_background:
              type: boolean
              default: false
              description: Подавление шума перед сменой голоса.

    ChangeForm:
      allOf:
        - { $ref: '#/components/schemas/ChangeRequest' }
        - type: object
          properties:
            file: { type: string, format: binary }

    DubRequest:
      allOf:
        - { $ref: '#/components/schemas/MediaSource' }
        - type: object
          description: |
            Полей `clone_strength` и `dub_voice` нет: голоса подбираются автоматически.
          properties:
            language:
              type: string
              enum: [ru, en, es, de, fr]
              default: en
              description: Целевой язык. Поле называется `language`, не `target_language`.
            source_lang: { type: string, enum: [auto, ru, en, es, de], default: auto }
            subtitles:
              type: boolean
              default: true
              description: Ссылка на них приедет в `result.subtitles_url` при любом `format`.
            format:
              type: string
              enum: [mp3, wav, video]
              default: video
              description: |
                `video` — `video_url` (mp4) и `audio_url` (mp3), `result.format: mp4`;
                `mp3` — только `audio_url`, `mp3`; `wav` — только `audio_url` (PCM 16 бит,
                44,1 кГц), `wav`. При `mp3`/`wav` `video_url` — `null`.
            keep_background: { type: boolean, default: true }

    DubForm:
      allOf:
        - { $ref: '#/components/schemas/DubRequest' }
        - type: object
          properties:
            file: { type: string, format: binary }

    TranscribeRequest:
      allOf:
        - { $ref: '#/components/schemas/MediaSource' }
        - type: object
          properties:
            language: { type: string, enum: [auto, ru, en], default: auto }
            speakers:
              type: string
              enum: [auto, '1', '2', '3+']
              default: auto
              description: |
                `3+` и `auto` означают одно и то же — «определи сам»: точное число движок
                принимает только для одного и двух голосов.
            mode:
              type: string
              enum: [verbatim, clean]
              default: verbatim
              description: Влияет и на текст в ответе, и на содержимое txt/srt/vtt.
            key_terms:
              type: array
              maxItems: 100
              items: { type: string, minLength: 1, maxLength: 48 }
              description: |
                Имена, термины и бренды. В multipart передаются повторяющимся полем либо
                строкой JSON.

    TranscribeForm:
      allOf:
        - { $ref: '#/components/schemas/TranscribeRequest' }
        - type: object
          properties:
            file: { type: string, format: binary }

    AlignRequest:
      allOf:
        - { $ref: '#/components/schemas/MediaSource' }
        - type: object
          required: [text]
          description: |
            Полей `format`, `language` и `speakers` нет: метод всегда отдаёт все три
            файла, а язык и спикеры на тайминги не влияют.
          properties:
            text:
              type: string
              minLength: 1
              maxLength: 600000
              description: Настоящий предел — 512 КиБ, он проверяется по байтам.
            level: { type: string, enum: [word, phrase], default: word }
            split: { type: string, enum: [short, medium, long], default: medium }

    AlignForm:
      allOf:
        - { $ref: '#/components/schemas/AlignRequest' }
        - type: object
          properties:
            file: { type: string, format: binary }

    VideoRequest:
      type: object
      required: [prompt]
      additionalProperties: false
      properties:
        prompt: { type: string, minLength: 1, maxLength: 5000 }
        duration_sec:
          type: integer
          minimum: 1
          maximum: 12
          default: 8
          description: Двенадцать секунд — настоящий потолок каталога моделей.
        resolution: { type: string, enum: ['480p', '720p', '1080p'], default: '720p' }
        ratio: { type: string, enum: ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'], default: '16:9' }

    AvatarForm:
      type: object
      required: [file]
      properties:
        file:
          type: string
          format: binary
          description: До 10 МиБ; jpg/jpeg, png, webp, mp4, mov, m4v, webm.
        name: { type: string, maxLength: 200, default: Персонаж }

    Avatar:
      type: object
      required: [id, object, name, status, created_at]
      properties:
        id: { type: string }
        object: { type: string, const: avatar }
        name: { type: string }
        preview_url:
          anyOf: [{ type: string }, { type: 'null' }]
        status: { type: string }
        created_at: { type: string, format: date-time }
        livemode: { type: boolean, default: true }

    AvatarList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/Avatar' }
        has_more: { type: boolean, default: false }
        next_cursor:
          anyOf: [{ type: string }, { type: 'null' }]

    AvatarDeleted:
      type: object
      required: [id, object]
      properties:
        id: { type: string }
        object: { type: string, const: avatar }
        deleted: { type: boolean, default: true }

    TalkingHeadRequest:
      type: object
      required: [avatar_id, text]
      additionalProperties: false
      properties:
        avatar_id: { type: string, minLength: 1, maxLength: 64 }
        text: { type: string, minLength: 40, maxLength: 700 }
        voice_id:
          anyOf: [{ type: string, maxLength: 64 }, { type: 'null' }]
          description: |
            Только каталожный. По умолчанию — голос каталога по умолчанию (своего голоса у
            персонажа нет). Личный голос или не-ASCII цифры — 422 `unsupported_voice`;
            больше 9 цифр или несуществующий — 404.

    WebhookEventType:
      type: string
      description: |
        Поддерживаются ровно три терминальных события. `job.queued`, `job.processing`,
        `balance.low` и `key.revoked` не поддерживаются: подписка на них отклоняется с
        перечислением доступных.
      enum: [job.succeeded, job.failed, job.canceled]

    WebhookCreateRequest:
      type: object
      required: [url, events]
      additionalProperties: false
      properties:
        url:
          type: string
          minLength: 8
          maxLength: 2000
          description: Только `https`, без логина в адресе, на публичный хост; иначе 422 `invalid_url`.
        events:
          type: array
          minItems: 1
          maxItems: 16
          items: { $ref: '#/components/schemas/WebhookEventType' }

    WebhookUpdateRequest:
      type: object
      additionalProperties: false
      properties:
        url:
          anyOf: [{ type: string, minLength: 8, maxLength: 2000 }, { type: 'null' }]
        events:
          anyOf:
            - type: array
              minItems: 1
              maxItems: 16
              items: { $ref: '#/components/schemas/WebhookEventType' }
            - { type: 'null' }
        status:
          anyOf: [{ type: string, enum: [enabled, disabled] }, { type: 'null' }]

    Webhook:
      type: object
      required: [id, object, url, events, status, livemode, created_at, updated_at]
      properties:
        id: { type: string, examples: ['4c1d7f92-6a83-4b0e-9f21-8d3c5e60ab74'] }
        object: { type: string, const: webhook_endpoint }
        url: { type: string }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEventType' }
        status:
          type: string
          enum: [enabled, failing, disabled]
          description: |
            `failing` — последняя доставка исчерпала повторы, первый успех вернёт
            `enabled`. `disabled` — выключено вручную или после 20 исчерпанных доставок
            подряд; обратно — только `PATCH` со `status: enabled`.
        livemode: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        last_success_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
        last_failure_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
        consecutive_failures: { type: integer, default: 0 }
        disabled_reason:
          anyOf: [{ type: string }, { type: 'null' }]
        secret:
          anyOf: [{ type: string }, { type: 'null' }]
          description: |
            Приходит только в ответе на создание и на ротацию. В остальных ответах —
            `null`: второй раз мы его не покажем.

    WebhookList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/Webhook' }
        has_more: { type: boolean, default: false }
        next_cursor:
          anyOf: [{ type: string }, { type: 'null' }]

    WebhookDeliveryAttempt:
      type: object
      required: [attempt, status]
      properties:
        attempt: { type: integer }
        at:
          anyOf: [{ type: string }, { type: 'null' }]
        status: { type: string }
        response_status:
          anyOf: [{ type: integer }, { type: 'null' }]
        duration_ms:
          anyOf: [{ type: integer }, { type: 'null' }]
        error:
          anyOf: [{ type: string }, { type: 'null' }]

    WebhookDelivery:
      type: object
      required: [id, object, endpoint_id, event_id, event_type, status, attempts, max_attempts, created_at, updated_at]
      properties:
        id: { type: string }
        object: { type: string, const: webhook_delivery }
        endpoint_id: { type: string }
        event_id: { type: string, examples: ['evt_3Fd8kLq2Wm7Rx9Tb4Nc1Vz'] }
        event_type: { $ref: '#/components/schemas/WebhookEventType' }
        job_id:
          anyOf: [{ type: string }, { type: 'null' }]
        status: { type: string, enum: [queued, delivering, succeeded, failed] }
        attempts: { type: integer }
        max_attempts: { type: integer, examples: [9] }
        response_status:
          anyOf: [{ type: integer }, { type: 'null' }]
        error:
          anyOf: [{ type: string }, { type: 'null' }]
        next_retry_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
        delivered_at:
          anyOf: [{ type: string, format: date-time }, { type: 'null' }]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        attempts_log:
          type: array
          items: { $ref: '#/components/schemas/WebhookDeliveryAttempt' }

    WebhookDeliveryList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/WebhookDelivery' }
        has_more: { type: boolean, default: false }
        next_cursor:
          anyOf: [{ type: string }, { type: 'null' }]

    WebhookEvent:
      type: object
      required: [id, type, created_at, livemode, data]
      properties:
        id: { type: string, examples: ['evt_3Fd8kLq2Wm7Rx9Tb4Nc1Vz'] }
        type: { $ref: '#/components/schemas/WebhookEventType' }
        created_at:
          type: string
          format: date-time
          description: Момент завершения задачи; одинаков во всех попытках доставки события.
        livemode: { type: boolean }
        data:
          type: object
          required: [object]
          properties:
            object: { $ref: '#/components/schemas/Job' }

    Account:
      type: object
      required: [object, chars_balance, chars_limit, spend_frozen, subscription_status, livemode]
      properties:
        object: { type: string, const: account }
        chars_balance:
          type: integer
          description: Кредиты API — отдельный от символов сайта баланс.
        chars_limit:
          type: integer
          description: Сколько кредитов выдано несгоревшими пакетами.
        spend_frozen:
          type: boolean
          description: |
            Всегда `false`: кредиты API оплачены отдельно и не замораживаются вместе с
            подпиской сайта. Поле оставлено ради совместимости.
        plan:
          anyOf: [{ type: string }, { type: 'null' }]
        plan_name:
          anyOf: [{ type: string }, { type: 'null' }]
        subscription_status: { type: string }
        resets_at:
          anyOf: [{ type: string }, { type: 'null' }]
          description: Дата следующего начисления. Без подписки её нет.
        livemode: { type: boolean }

    UsageBucket:
      type: object
      required: [key, chars]
      properties:
        key: { type: string }
        label:
          anyOf: [{ type: string }, { type: 'null' }]
        chars: { type: integer }

    UsageSource:
      type: object
      required: [source, chars]
      properties:
        source: { type: string, examples: [api, web] }
        chars: { type: integer }

    Usage:
      type: object
      required: [object, from, to, group_by, total_chars, data, by_source]
      properties:
        object: { type: string, const: usage }
        from: { type: string }
        to: { type: string }
        group_by: { type: string, enum: [day, type] }
        total_chars: { type: integer }
        data:
          type: array
          items: { $ref: '#/components/schemas/UsageBucket' }
        by_source:
          type: array
          items: { $ref: '#/components/schemas/UsageSource' }

    EstimateRequest:
      type: object
      required: [type]
      additionalProperties: false
      properties:
        type:
          type: string
          minLength: 1
          maxLength: 32
          description: Пока поддержан только `tts`.
        text: { type: string, maxLength: 20000, default: '' }
        normalize: { type: boolean, default: false }

    Estimate:
      type: object
      required: [object, type, chars, credits]
      properties:
        object: { type: string, const: estimate }
        type: { type: string }
        chars:
          type: integer
          description: |
            Символов в тексте после обрезки пробелов по краям. `normalize` на число не
            влияет: платится набранный текст, а не развёрнутый провайдером.
        credits: { type: integer, description: Сколько спишется с баланса. }
