{
 "openapi": "3.1.0",
 "info": {
  "title": "tonvio Text-to-Speech API",
  "version": "1.0.0",
  "summary": "Asynchronous HTTP API for speech synthesis and voice cloning.",
  "description": "Public, stable HTTP API of tonvio.\n\nThe API is asynchronous: `POST /speech` accepts a job and answers immediately with a job id, and the client polls `GET /speech/{id}` until the status is terminal. There are no long-held connections, so the API works reliably behind a CDN.\n\nEvery synthesis job is paid for in character credits (1 character = 1 credit) taken from purchased credit packs only — subscriptions and bonus credits do not pay for the API. Read requests never spend balance.\n\nA key carries its own set of rights (scopes) and, optionally, its own limits: request rate, slots, characters per request, daily and monthly volume, allowed providers and the clone quota. A key with no numbers of its own runs on the owner's plan. `GET /account` reports what is actually in force and where each number came from.\n\nAll timestamps are ISO 8601 with an explicit UTC offset. All monetary and volume figures are in characters.\n\n## Webhooks\n\nInstead of polling you can have us call you. Set a receiver address for a key in the web app (API service → your key → Webhook); we then issue a signing secret, shown **once**.\n\nEvery delivery carries `X-Tonvio-Signature: t=<unix>,v1=<hex>`, where the hex is HMAC-SHA256 over `<t>.<raw request body>` keyed with that secret. Verify it against the RAW bytes before trusting the body, compare in constant time, and reject timestamps older than 5 minutes.\n\nDelivery is at-least-once — deduplicate on the `id` field (`X-Tonvio-Delivery`). Retries: 5 attempts over roughly 2.6 hours (30s, 5m, 30m, 2h); `410 Gone` stops them. We do not follow redirects, and the receiver must be a public HTTPS endpoint.",
  "termsOfService": "https://tonvio.net/terms",
  "contact": {
   "name": "tonvio support",
   "url": "https://tonvio.net/contact",
   "email": "support@tonvio.net"
  },
  "license": {
   "name": "Proprietary — tonvio Terms of Service",
   "url": "https://tonvio.net/terms"
  }
 },
 "externalDocs": {
  "description": "Human-readable API reference",
  "url": "https://tonvio.net/text-to-speech-api"
 },
 "servers": [
  {
   "url": "https://api.tonvio.net/api/v1",
   "description": "Production"
  }
 ],
 "security": [
  {
   "bearerAuth": []
  },
  {
   "apiKeyHeader": []
  }
 ],
 "tags": [
  {
   "name": "Speech",
   "description": "Create, poll, list and cancel synthesis jobs."
  },
  {
   "name": "Voices",
   "description": "Read the voice catalog available to this key."
  },
  {
   "name": "Account",
   "description": "Balance, effective limits and usage of this key."
  },
  {
   "name": "Voice clones",
   "description": "Order, poll and delete voice clones."
  }
 ],
 "paths": {
  "/speech": {
   "post": {
    "tags": [
     "Speech"
    ],
    "operationId": "createSpeech",
    "summary": "Create a synthesis job",
    "description": "Accepts the job and returns its id immediately; the audio is produced in the background. Requires the `speech` scope.\n\nCredits are reserved on acceptance and charged on completion; a failed or cancelled job releases the reservation.\n\nIf the target provider does not understand a piece of markup in the text, the markup is normalised before the text is priced, and every intervention is reported in `warnings` — a removed tag is not charged for.",
    "parameters": [
     {
      "$ref": "#/components/parameters/IdempotencyKeyHeader"
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SpeechRequest"
       },
       "example": {
        "text": "Hello from tonvio!",
        "voice_id": "el_XXXX",
        "language": "en",
        "idempotency_key": "9d1a1a2e-0f2f-4a1b-9f0e-3a5d1c7b2e11"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Idempotent replay: this idempotency key was already used with the same parameters. The body carries the CURRENT status of the original job, which may already be terminal.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SpeechAccepted"
        }
       }
      }
     },
     "202": {
      "description": "Job accepted and queued.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SpeechAccepted"
        },
        "example": {
         "id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
         "status": "queued",
         "characters": 18,
         "queue_position": 1
        }
       }
      }
     },
     "400": {
      "description": "Invalid request. Codes: `invalid_request` (see `message`), `voice_engine_required`, `text_too_long` (with `max`), `text_too_short` (with `min`), `voice_language_not_available` (with `requested` and `available`), `elevenlabs_voice_id_unsupported`, `invalid_json`, `bad_request`.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "example": {
         "error": "invalid_request",
         "message": "voice_id or elevenlabs_voice_id is required"
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "402": {
      "description": "Not enough purchased characters. The body carries `feature`, `need` and `available`.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "example": {
         "error": "insufficient_balance",
         "feature": "api",
         "need": 18240,
         "available": 900
        }
       }
      }
     },
     "403": {
      "description": "Forbidden. Codes: `voice_not_allowed`, `api_disabled`, `api_key_frozen`, `insufficient_scope` (with `required`).",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Conflict. Codes: `idempotency_mismatch` (same key, different parameters), `idempotency_key_consumed` (the original result was deleted — use a new key), `voice_warming` (retry shortly), `clone_unavailable`, `api_provider_disabled` (retrying will not help).",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "413": {
      "$ref": "#/components/responses/PayloadTooLarge"
     },
     "415": {
      "$ref": "#/components/responses/UnsupportedMediaType"
     },
     "429": {
      "description": "Throttled. Codes: `rate_limited` (with `retry_after` and `limit_per_min`), `too_many_jobs` (with `active_and_queued`, `limit` and `scope` — `account` for the ceiling shared with the web app, `key` for this key's own), `quota_exceeded` (with `period`, `used`, `limit`, `resets_at`).",
      "headers": {
       "Retry-After": {
        "$ref": "#/components/headers/RetryAfter"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "example": {
         "error": "quota_exceeded",
         "scope": "key",
         "requested": 12000,
         "period": "daily",
         "used": 995000,
         "limit": 1000000,
         "resets_at": "2026-09-06T00:00:00Z"
        }
       }
      }
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     },
     "503": {
      "description": "Temporarily unavailable. Codes: `generation_unavailable` (with `dependency`), `service_unavailable`, `service_maintenance`. All are retryable after the indicated delay.",
      "headers": {
       "Retry-After": {
        "$ref": "#/components/headers/RetryAfter"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "get": {
    "tags": [
     "Speech"
    ],
    "operationId": "listSpeech",
    "summary": "List jobs of this key",
    "description": "Jobs created by the CURRENT key only, newest first. Cursor-paginated. Requires the `speech:read` scope (the `speech` scope covers it).",
    "parameters": [
     {
      "$ref": "#/components/parameters/ListLimit"
     },
     {
      "$ref": "#/components/parameters/Cursor"
     }
    ],
    "responses": {
     "200": {
      "description": "A page of jobs.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/JobPage"
        }
       }
      }
     },
     "400": {
      "description": "`invalid_cursor` — the pagination cursor is unreadable or damaged.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     }
    }
   }
  },
  "/speech/{id}": {
   "get": {
    "tags": [
     "Speech"
    ],
    "operationId": "getSpeech",
    "summary": "Get a job",
    "description": "Status of a job created by the CURRENT key, and its audio URL once the job is completed. Requires the `speech:read` scope.\n\nPoll every 2–3 seconds. `progress` reaches 100 only together with `status: completed`; while the job is still running it is capped at 99, so \"100%\" never means \"nearly ready\".\n\nThe `audio_url` link is short-lived (about six hours). Ask for the job again to get a fresh one.\n\nInstead of a fixed 2–3 second interval, prefer the `poll_after_seconds` field of the response — the server says how long it is worth waiting. To avoid polling almost entirely, add `?wait=60`: the request is then held open until the job finishes (long polling).",
    "parameters": [
     {
      "$ref": "#/components/parameters/JobId"
     },
     {
      "$ref": "#/components/parameters/Wait"
     }
    ],
    "responses": {
     "200": {
      "description": "The job.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Job"
        },
        "example": {
         "id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
         "status": "completed",
         "progress": 100,
         "characters": 18,
         "credits_charged": 18,
         "provider": "voicegen",
         "language": null,
         "voice_id": "el_XXXX",
         "elevenlabs_voice_id": "XXXX",
         "voice_engine": "engine_id",
         "audio_url": "https://cdn.tonvio.net/audio/...",
         "duration_seconds": 2.4,
         "error": null,
         "error_code": null,
         "error_message": null,
         "created_at": "2026-09-05T09:12:44.000Z",
         "started_at": "2026-09-05T09:12:45.000Z",
         "finished_at": "2026-09-05T09:12:58.000Z"
        }
       }
      }
     },
     "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"
     },
     "503": {
      "description": "`delivery_unavailable` — the job is finished but its download link could not be issued right now. Retry after the indicated delay; nothing is lost.",
      "headers": {
       "Retry-After": {
        "$ref": "#/components/headers/RetryAfter"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "Speech"
    ],
    "operationId": "deleteSpeech",
    "summary": "Delete a job",
    "description": "Deletes the job and its audio. If the job is still running it is cancelled first — `canceled: true` in the response means this very call stopped it.\n\nRequires the `speech` scope: the call is irreversible and moves money.\n\n**The idempotency key is not released.** A deleted job keeps its key occupied, and repeating the original request returns `409 idempotency_key_consumed`. Use a fresh key for a new synthesis — the response says so up front via `idempotency_key_reusable: false`, so you do not have to learn it from a failed retry.\n\nWhile the audio is being assembled the endpoint answers `409 finalizing`, and while the job is busy with another operation, `409 job_busy`. Both are temporary — retry in a few seconds.",
    "parameters": [
     {
      "$ref": "#/components/parameters/JobId"
     }
    ],
    "responses": {
     "200": {
      "description": "The job and its audio are deleted.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "id",
          "deleted",
          "canceled",
          "idempotency_key_reusable"
         ],
         "properties": {
          "id": {
           "type": "string",
           "format": "uuid"
          },
          "deleted": {
           "type": "boolean",
           "const": true
          },
          "canceled": {
           "type": "boolean",
           "description": "True when this call stopped a still-running job (its credit reservation was settled)."
          },
          "idempotency_key_reusable": {
           "type": "boolean",
           "const": false,
           "description": "Always false: the key of a deleted job stays occupied. Use a new one."
          }
         }
        },
        "example": {
         "id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
         "deleted": true,
         "canceled": false,
         "idempotency_key_reusable": false
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     },
     "409": {
      "description": "The job cannot be deleted right now: `finalizing` (audio is being assembled) or `job_busy`.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "example": {
         "error": "finalizing",
         "status": "processing"
        }
       }
      }
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     }
    }
   }
  },
  "/speech/{id}/cancel": {
   "post": {
    "tags": [
     "Speech"
    ],
    "operationId": "cancelSpeech",
    "summary": "Cancel a job",
    "description": "Stops a queued or running job. Requires the `speech` scope: cancelling changes both state and money, so a read-only key cannot do it.\n\n**The refund is not always the full amount.** Fragments the provider has already rendered are charged for — we have already paid for them — and only the remainder of the reservation comes back. Cancel early rather than late: cancelling at 95% returns very little. If nothing has been rendered yet, the whole reservation is returned, exactly as before.\n\nOnce rendering is over and final assembly has begun, the job cannot be cancelled at all: within seconds it completes and is charged in full.",
    "parameters": [
     {
      "$ref": "#/components/parameters/JobId"
     }
    ],
    "responses": {
     "200": {
      "description": "The job is cancelled.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "id",
          "status"
         ],
         "properties": {
          "id": {
           "type": "string",
           "format": "uuid"
          },
          "status": {
           "type": "string",
           "const": "canceled"
          }
         }
        },
        "example": {
         "id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
         "status": "canceled"
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     },
     "409": {
      "description": "Codes: `job_busy` (a fragment is being rendered right now — retry in a moment), `not_cancellable` (the job is already terminal). Both carry the current `status`.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     }
    }
   }
  },
  "/voices": {
   "get": {
    "tags": [
     "Voices"
    ],
    "operationId": "listVoices",
    "summary": "List voices",
    "description": "The catalog this key can actually synthesize with: voices of providers the key is not allowed to use are not listed. Requires the `voices` scope.",
    "parameters": [
     {
      "name": "provider",
      "in": "query",
      "required": false,
      "description": "Narrow the list to one provider. The accepted values are the `provider` values returned by this endpoint; an unknown value returns 400 rather than an empty page. Intersected with the key's own allowance — a request cannot widen it.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "q",
      "in": "query",
      "required": false,
      "description": "Free-text search over name, description, accent and identifiers.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "language",
      "in": "query",
      "required": false,
      "description": "Language filter, BCP 47 (`en`, `ru`, `pt-BR`). Matches the voice's base language or any language of its stack.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "gender",
      "in": "query",
      "required": false,
      "description": "Gender filter as published on the voice.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "description": "Page size. Out-of-range or unreadable values fall back to the default; the applied value is echoed in the response.",
      "schema": {
       "type": "integer",
       "minimum": 1,
       "maximum": 200,
       "default": 50
      }
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "description": "Offset pagination. The applied value is echoed in the response.",
      "schema": {
       "type": "integer",
       "minimum": 0,
       "default": 0
      }
     }
    ],
    "responses": {
     "200": {
      "description": "A page of voices.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/VoicePage"
        }
       }
      }
     },
     "400": {
      "description": "`invalid_request` — the `provider` filter is not a known provider; `message` lists the accepted values.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     }
    }
   }
  },
  "/voices/{id}": {
   "get": {
    "tags": [
     "Voices"
    ],
    "operationId": "getVoice",
    "summary": "Get one voice",
    "description": "Accepts the canonical `id` of the voice or its identifier in the external catalog the first provider is built on. A voice this key cannot synthesize with does not exist for it — the answer is 404. Requires the `voices` scope.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "description": "Voice id, or the same voice's identifier in the external catalog.",
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The voice.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "voice"
         ],
         "properties": {
          "voice": {
           "$ref": "#/components/schemas/Voice"
          }
         }
        }
       }
      }
     },
     "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"
     }
    }
   }
  },
  "/account": {
   "get": {
    "tags": [
     "Account"
    ],
    "operationId": "getAccount",
    "summary": "Balance, effective limits and usage",
    "description": "What this key can actually spend and what it will run into. Requires the `account` scope.\n\n`limits` are EFFECTIVE: the owner's plan with the key's own numbers laid over it. `limit_sources` says where each number came from — `key`, `plan` or `default`. Without that map a number is not actionable: the owner would not know where to change it.\n\n`usage.active_jobs` and `usage.queued_jobs` cover the whole account, including the web app and other keys; `usage.key` is this key alone.",
    "responses": {
     "200": {
      "description": "The account report.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "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"
     }
    }
   }
  },
  "/voice-clones": {
   "post": {
    "tags": [
     "Voice clones"
    ],
    "operationId": "createVoiceClone",
    "summary": "Order a voice clone",
    "description": "Asynchronous, like synthesis: the response is 202 with our request id, and the client polls `GET /voice-clones/{id}` no more often than `poll_after_seconds` says. Building a voice takes minutes, not seconds. Requires the `clone` scope, which is NOT part of a key's factory set.\n\nThe `Idempotency-Key` header is REQUIRED here, unlike on `/speech`: repeating a synthesis costs characters, while repeating a clone order costs a slot at the provider, and no retry gives that slot back.\n\nTwo body forms, same field rules:\n\n* `multipart/form-data` — a private clone from your own sample. THE TEXT FIELDS MUST COME BEFORE THE FILE, otherwise they never reach the server. The format is checked against the real bytes, not the file name.\n* `application/json` — a language variant of a catalog voice (`mode=library`). A link to a file is deliberately not accepted.\n\nA language variant is SHARED: every user of the service gets the built voice, it cannot be deleted, and ordering one therefore requires an explicit `acknowledge_shared: true`. What you actually got is always stated in the `visibility` field of the response.",
    "parameters": [
     {
      "$ref": "#/components/parameters/IdempotencyKeyRequiredHeader"
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/CloneSampleForm"
       },
       "encoding": {
        "audio": {
         "contentType": "audio/mpeg, audio/wav, audio/mp4, audio/webm"
        }
       }
      },
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/CloneLibraryRequest"
       },
       "example": {
        "mode": "library",
        "name": "Anna (de)",
        "consent": "I confirm this catalog voice may be built for German.",
        "elevenlabs_voice_id": "el_XXXX",
        "languages": [
         "de"
        ],
        "acknowledge_shared": true
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Idempotent replay: the same `Idempotency-Key` with the same body. The body carries the CURRENT state of the original request.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CloneJob"
        }
       }
      }
     },
     "202": {
      "description": "The order is accepted.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CloneJob"
        }
       }
      }
     },
     "400": {
      "description": "Codes: `idempotency_key_required`, `invalid_request`, `consent_required` (the statement must be 20–500 characters), `shared_voice_consent_required` (`acknowledge_shared: true` is missing), `invalid_language` (with the accepted set in `allowed`).",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "description": "Codes: `clone_feature_disabled` (cloning is off for this account), `clones_not_allowed` (the key's clone quota is zero), `insufficient_scope`, `api_key_frozen`, `api_disabled`.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Codes: `idempotency_mismatch`, `clone_limit_reached` (with `used` and `limit`), `voice_already_available` (this language variant already exists — there is nothing to order).",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "413": {
      "description": "`file_too_large` — the sample is over the limit; `max_bytes` says what the limit is.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "example": {
         "error": "file_too_large",
         "max_bytes": 15728640
        }
       }
      }
     },
     "415": {
      "description": "`unsupported_format` — the uploaded file is not supported audio. The real bytes are checked, not the file name.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "422": {
      "description": "`insufficient_balance` — not enough purchased characters for the order; `need` says how many it costs.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Codes: `clone_rate_limited` (clone orders are limited separately from the general request rate — see `limit_per_hour` and `retry_after`), `rate_limited`.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     },
     "501": {
      "description": "`clone_not_supported_for_provider` — this provider cannot build this kind of clone; `provider` and `mode` say which combination was refused. Nothing was uploaded and nothing was reserved.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "503": {
      "description": "`clone_service_unavailable` — cloning is temporarily unavailable; retry after the indicated delay.",
      "headers": {
       "Retry-After": {
        "$ref": "#/components/headers/RetryAfter"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "get": {
    "tags": [
     "Voice clones"
    ],
    "operationId": "listVoiceClones",
    "summary": "List clone requests of this key",
    "description": "Clone requests made by the CURRENT key, newest first, cursor-paginated. Requires the `clone` scope.",
    "parameters": [
     {
      "$ref": "#/components/parameters/ListLimit"
     },
     {
      "$ref": "#/components/parameters/Cursor"
     }
    ],
    "responses": {
     "200": {
      "description": "A page of clone requests.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CloneJobPage"
        }
       }
      }
     },
     "400": {
      "description": "`invalid_cursor` — the pagination cursor is unreadable or damaged.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     },
     "503": {
      "description": "`clone_service_unavailable` — cloning is temporarily unavailable; retry after the indicated delay.",
      "headers": {
       "Retry-After": {
        "$ref": "#/components/headers/RetryAfter"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/voice-clones/{id}": {
   "get": {
    "tags": [
     "Voice clones"
    ],
    "operationId": "getVoiceClone",
    "summary": "Get a clone request",
    "description": "State of a clone request made by the CURRENT key. Once `status` is `completed`, `voice_id` holds the identifier to pass to `POST /speech`. Requires the `clone` scope.\n\nA private clone is not in the public catalog and has no default engine — when synthesizing with it, pass `voice_engine` explicitly.",
    "parameters": [
     {
      "$ref": "#/components/parameters/CloneId"
     }
    ],
    "responses": {
     "200": {
      "description": "The clone request.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CloneJob"
        }
       }
      }
     },
     "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"
     },
     "503": {
      "description": "`clone_service_unavailable` — cloning is temporarily unavailable; retry after the indicated delay.",
      "headers": {
       "Retry-After": {
        "$ref": "#/components/headers/RetryAfter"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "Voice clones"
    ],
    "operationId": "deleteVoiceClone",
    "summary": "Drop the request or delete the clone",
    "description": "Drops a pending request or deletes a finished private clone and frees the provider slot. Idempotent. Requires the `clone` scope.",
    "parameters": [
     {
      "$ref": "#/components/parameters/CloneId"
     }
    ],
    "responses": {
     "204": {
      "description": "Deleted. No body.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     },
     "409": {
      "description": "Codes: `clone_in_progress` (the provider still holds the request — wait for a terminal status), `clone_in_use` (an unfinished synthesis job is using this voice), `shared_voice_not_deletable` (a shared language variant belongs to every user and cannot be deleted).",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     },
     "503": {
      "description": "`clone_service_unavailable` — cloning is temporarily unavailable; retry after the indicated delay.",
      "headers": {
       "Retry-After": {
        "$ref": "#/components/headers/RetryAfter"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/voices/{id}/warm": {
   "get": {
    "tags": [
     "Voices"
    ],
    "operationId": "getVoiceWarmState",
    "summary": "Check whether a voice is ready",
    "description": "Reports whether a voice is ready for synthesis, without starting anything. `cold` means nobody has warmed it up yet. Requires the `voices` scope.\n\nA voice from the external catalogue has to be registered with the provider before its first use. Until that happens `POST /speech` refuses with `voice_warming`.",
    "parameters": [
     {
      "$ref": "#/components/parameters/VoiceIdPath"
     }
    ],
    "responses": {
     "200": {
      "description": "Current warm-up state of the voice.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "id",
          "status",
          "ready",
          "poll_after_seconds"
         ],
         "properties": {
          "id": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "enum": [
            "ready",
            "warming",
            "cold"
           ]
          },
          "ready": {
           "type": "boolean",
           "description": "True when the voice can be used for synthesis right now."
          },
          "poll_after_seconds": {
           "type": [
            "integer",
            "null"
           ],
           "description": "Seconds to wait before asking again; `null` once the voice is ready."
          }
         }
        },
        "example": {
         "id": "el_XXXX",
         "status": "warming",
         "ready": false,
         "poll_after_seconds": 15
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     },
     "422": {
      "description": "The voice cannot be warmed up: the provider refused to register it. Retrying will never help.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "example": {
         "error": "voice_not_warmable",
         "message": "The provider refused to register this voice; it cannot be used for synthesis."
        }
       }
      }
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     }
    }
   },
   "post": {
    "tags": [
     "Voices"
    ],
    "operationId": "warmVoice",
    "summary": "Warm a voice up",
    "description": "Starts warming the voice up so the first paid synthesis does not have to wait. Requires the `voices` scope. Safe to call repeatedly: if the warm-up is already running nothing is started a second time and your hourly allowance is not spent.\n\nThe allowance is 60 real warm-up starts per account per hour, shared with the web app; going over it returns `429 rate_limited` with `Retry-After`.\n\nAnswers `202` once a warm-up is under way; poll `GET /voices/{id}/warm` (or just retry the synthesis) after `poll_after_seconds`.",
    "parameters": [
     {
      "$ref": "#/components/parameters/VoiceIdPath"
     }
    ],
    "responses": {
     "200": {
      "description": "The voice is already ready — nothing was started.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "id",
          "status",
          "ready",
          "poll_after_seconds"
         ],
         "properties": {
          "id": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "enum": [
            "ready",
            "warming",
            "cold"
           ]
          },
          "ready": {
           "type": "boolean",
           "description": "True when the voice can be used for synthesis right now."
          },
          "poll_after_seconds": {
           "type": [
            "integer",
            "null"
           ],
           "description": "Seconds to wait before asking again; `null` once the voice is ready."
          }
         }
        },
        "example": {
         "id": "el_XXXX",
         "status": "warming",
         "ready": false,
         "poll_after_seconds": 15
        }
       }
      }
     },
     "202": {
      "description": "A warm-up is under way.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "id",
          "status",
          "ready",
          "poll_after_seconds"
         ],
         "properties": {
          "id": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "enum": [
            "ready",
            "warming",
            "cold"
           ]
          },
          "ready": {
           "type": "boolean",
           "description": "True when the voice can be used for synthesis right now."
          },
          "poll_after_seconds": {
           "type": [
            "integer",
            "null"
           ],
           "description": "Seconds to wait before asking again; `null` once the voice is ready."
          }
         }
        },
        "example": {
         "id": "el_XXXX",
         "status": "warming",
         "ready": false,
         "poll_after_seconds": 15
        }
       }
      }
     },
     "401": {
      "$ref": "#/components/responses/Unauthorized"
     },
     "403": {
      "$ref": "#/components/responses/Forbidden"
     },
     "404": {
      "$ref": "#/components/responses/NotFound"
     },
     "422": {
      "description": "The voice cannot be warmed up: the provider refused to register it. Retrying will never help.",
      "headers": {
       "X-Request-Id": {
        "$ref": "#/components/headers/RequestId"
       },
       "RateLimit-Limit": {
        "$ref": "#/components/headers/RateLimitLimit"
       },
       "RateLimit-Remaining": {
        "$ref": "#/components/headers/RateLimitRemaining"
       },
       "RateLimit-Reset": {
        "$ref": "#/components/headers/RateLimitReset"
       },
       "RateLimit-Policy": {
        "$ref": "#/components/headers/RateLimitPolicy"
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "example": {
         "error": "voice_not_warmable",
         "message": "The provider refused to register this voice; it cannot be used for synthesis."
        }
       }
      }
     },
     "429": {
      "$ref": "#/components/responses/RateLimited"
     },
     "500": {
      "$ref": "#/components/responses/InternalError"
     }
    }
   }
  }
 },
 "components": {
  "securitySchemes": {
   "bearerAuth": {
    "type": "http",
    "scheme": "bearer",
    "bearerFormat": "tvk",
    "description": "`Authorization: Bearer tvk_…`. Keys are server-side secrets: never ship them in front-end code."
   },
   "apiKeyHeader": {
    "type": "apiKey",
    "in": "header",
    "name": "X-API-Key",
    "description": "`X-API-Key: tvk_…`. Equivalent to the Bearer form; send one or the other, not both."
   }
  },
  "parameters": {
   "JobId": {
    "name": "id",
    "in": "path",
    "required": true,
    "description": "Job id returned by `POST /speech`.",
    "schema": {
     "type": "string",
     "format": "uuid"
    }
   },
   "CloneId": {
    "name": "id",
    "in": "path",
    "required": true,
    "description": "Clone request id returned by `POST /voice-clones`.",
    "schema": {
     "type": "string",
     "format": "uuid"
    }
   },
   "ListLimit": {
    "name": "limit",
    "in": "query",
    "required": false,
    "description": "Page size. Unreadable or out-of-range values fall back to the default.",
    "schema": {
     "type": "integer",
     "minimum": 1,
     "maximum": 100,
     "default": 20
    }
   },
   "Cursor": {
    "name": "cursor",
    "in": "query",
    "required": false,
    "description": "Opaque cursor from `next_cursor` of the previous page. Treat it as a token — its contents are not part of the contract.",
    "schema": {
     "type": "string"
    }
   },
   "IdempotencyKeyHeader": {
    "name": "Idempotency-Key",
    "in": "header",
    "required": false,
    "description": "Makes retries safe: the same key with the same parameters returns the original job instead of creating a second one. May also be passed as `idempotency_key` in the body; if both are present they must match. Without a key every call creates a new job.",
    "schema": {
     "type": "string",
     "maxLength": 128
    }
   },
   "IdempotencyKeyRequiredHeader": {
    "name": "Idempotency-Key",
    "in": "header",
    "required": true,
    "description": "REQUIRED for clone orders: a repeated order costs a slot at the provider, and no retry gives that slot back.",
    "schema": {
     "type": "string",
     "maxLength": 128
    }
   },
   "Wait": {
    "name": "wait",
    "in": "query",
    "required": false,
    "description": "Long polling: hold the request open for up to this many seconds, and answer as soon as the job reaches a terminal status. 0 or omitted keeps today's behaviour — an immediate answer. Values above the 60-second ceiling are clamped rather than rejected, so a client asking for more simply gets an answer after 60 seconds and carries on polling. Under heavy load the endpoint may answer immediately even with `wait` set: that is deliberate degradation, not an error — follow `poll_after_seconds` and keep polling.",
    "schema": {
     "type": "integer",
     "minimum": 0,
     "maximum": 60
    },
    "examples": {
     "waitOneMinute": {
      "value": 60
     }
    }
   },
   "VoiceIdPath": {
    "name": "id",
    "in": "path",
    "required": true,
    "description": "Voice id, or the same voice's identifier in the external catalog.",
    "schema": {
     "type": "string"
    }
   }
  },
  "headers": {
   "RequestId": {
    "description": "Correlation id of this request. Quote it when contacting support.",
    "schema": {
     "type": "string"
    }
   },
   "RateLimitLimit": {
    "description": "Requests per minute in force for this key.",
    "schema": {
     "type": "integer"
    }
   },
   "RateLimitRemaining": {
    "description": "How much of the allowance is left right now.",
    "schema": {
     "type": "integer"
    }
   },
   "RateLimitReset": {
    "description": "Seconds until the allowance is fully restored.",
    "schema": {
     "type": "integer"
    }
   },
   "RateLimitPolicy": {
    "description": "The policy in force, e.g. `300;w=60;burst=600`.",
    "schema": {
     "type": "string"
    }
   },
   "RetryAfter": {
    "description": "Seconds to wait before retrying.",
    "schema": {
     "type": "integer"
    }
   }
  },
  "responses": {
   "Unauthorized": {
    "description": "Codes: `missing_api_key`, `invalid_api_key` (unknown or revoked), `api_key_expired` (the key passed its expiry date).",
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/Error"
      },
      "example": {
       "error": "invalid_api_key"
      }
     }
    }
   },
   "Forbidden": {
    "description": "Codes: `insufficient_scope` (the key has no right to this endpoint; `required` lists the rights any one of which would have worked), `api_key_frozen` (all rights removed — reversible), `api_disabled` (API access is off for this account).",
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/Error"
      },
      "example": {
       "error": "insufficient_scope",
       "required": [
        "speech:read"
       ]
      }
     }
    }
   },
   "NotFound": {
    "description": "`not_found`. A resource belonging to another key of the same account is indistinguishable from one that does not exist.",
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/Error"
      },
      "example": {
       "error": "not_found"
      }
     }
    }
   },
   "RateLimited": {
    "description": "`rate_limited` — too many requests for this key. Wait `retry_after` seconds.",
    "headers": {
     "Retry-After": {
      "$ref": "#/components/headers/RetryAfter"
     },
     "RateLimit-Limit": {
      "$ref": "#/components/headers/RateLimitLimit"
     },
     "RateLimit-Remaining": {
      "$ref": "#/components/headers/RateLimitRemaining"
     },
     "RateLimit-Reset": {
      "$ref": "#/components/headers/RateLimitReset"
     },
     "RateLimit-Policy": {
      "$ref": "#/components/headers/RateLimitPolicy"
     }
    },
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/Error"
      },
      "example": {
       "error": "rate_limited",
       "retry_after": 2,
       "limit_per_min": 300
      }
     }
    }
   },
   "PayloadTooLarge": {
    "description": "`payload_too_large` — the request body exceeds the limit; `max_bytes` says what the limit is.",
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/Error"
      },
      "example": {
       "error": "payload_too_large",
       "max_bytes": 8388608
      }
     }
    }
   },
   "UnsupportedMediaType": {
    "description": "`unsupported_media_type` — `application/json` is required.",
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/Error"
      },
      "example": {
       "error": "unsupported_media_type",
       "expected": "application/json"
      }
     }
    }
   },
   "InternalError": {
    "description": "`internal_error` — an unexpected server error. The body carries `request_id`; give it to support.",
    "headers": {
     "X-Request-Id": {
      "$ref": "#/components/headers/RequestId"
     }
    },
    "content": {
     "application/json": {
      "schema": {
       "$ref": "#/components/schemas/Error"
      },
      "example": {
       "error": "internal_error",
       "request_id": "req-0f2a…"
      }
     }
    }
   }
  },
  "schemas": {
   "Error": {
    "type": "object",
    "title": "Error",
    "description": "Every failure has the same envelope: a machine `error` code plus whatever fields that particular code carries. Branch on `error`, never on the wording of `message`.",
    "required": [
     "error"
    ],
    "properties": {
     "error": {
      "type": "string",
      "description": "Stable machine code."
     },
     "message": {
      "type": "string",
      "description": "English explanation for a human. Not a contract — do not parse it."
     },
     "request_id": {
      "type": "string",
      "description": "Correlation id, present on 5xx. Quote it when contacting support."
     },
     "retry_after": {
      "type": "integer",
      "description": "Seconds to wait before retrying, where a retry makes sense."
     }
    },
    "additionalProperties": true
   },
   "JobStatus": {
    "type": "string",
    "description": "`queued` → `processing` → `completed`. Terminal: `completed`, `failed`, `canceled`.",
    "enum": [
     "queued",
     "processing",
     "completed",
     "failed",
     "canceled"
    ]
   },
   "MarkupWarning": {
    "type": "object",
    "title": "MarkupWarning",
    "description": "What was done to the text so the target provider would understand it. Reported only when there was an intervention: a removed tag is not charged for.",
    "required": [
     "code",
     "tag",
     "count"
    ],
    "properties": {
     "code": {
      "type": "string",
      "enum": [
       "markup_removed",
       "markup_mapped"
      ],
      "description": "`markup_mapped` — the tag was rewritten into the provider's own spelling; `markup_removed` — it was dropped together with its cost."
     },
     "tag": {
      "type": "string",
      "description": "The tag exactly as it was sent."
     },
     "replacement": {
      "type": "string",
      "description": "What it became. Only for `markup_mapped`."
     },
     "count": {
      "type": "integer",
      "description": "How many times it occurred."
     }
    }
   },
   "SpeechSettings": {
    "type": "object",
    "title": "SpeechSettings",
    "description": "Optional tuning. Unknown keys are rejected rather than silently ignored.",
    "properties": {
     "stability": {
      "type": "number",
      "minimum": 0,
      "maximum": 1
     },
     "settings_preset": {
      "type": "string",
      "maxLength": 100
     },
     "delay_between_chunks": {
      "type": "number",
      "minimum": 0,
      "maximum": 60,
      "description": "Seconds of silence inserted between rendered fragments."
     }
    },
    "additionalProperties": false
   },
   "SpeechRequest": {
    "type": "object",
    "title": "SpeechRequest",
    "required": [
     "text"
    ],
    "description": "Exactly one voice identifier is required: `voice_id` or `elevenlabs_voice_id`.",
    "properties": {
     "text": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1000000,
      "description": "The text to voice. The plan also caps characters per request — see `limits.max_chars_per_request` in `GET /account`. Some providers additionally refuse text shorter than a few dozen characters."
     },
     "voice_id": {
      "type": "string",
      "maxLength": 200,
      "description": "Catalog voice id from `GET /voices`, or the `voice_id` of a finished private clone."
     },
     "elevenlabs_voice_id": {
      "type": "string",
      "maxLength": 200,
      "description": "The voice's identifier in the external catalog the first provider is built on — an alternative to `voice_id`. Providers that do not use that catalog reject it with `elevenlabs_voice_id_unsupported`."
     },
     "voice_engine": {
      "type": "string",
      "maxLength": 100,
      "description": "Synthesis engine. Optional: with a catalog voice the voice's default engine is used. A private clone has no default engine, so pass it explicitly. Providers that have no notion of an engine ignore the field."
     },
     "model": {
      "type": "string",
      "maxLength": 100,
      "description": "Alias of `voice_engine`. If both are sent they must be identical."
     },
     "language": {
      "type": "string",
      "maxLength": 35,
      "pattern": "^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$",
      "description": "Language of the synthesis, BCP 47 (`ru`, `en`, `pt-BR`). Where a voice is a stack of per-language clones, silence is not \"let the engine decide\" — it is an implicit choice, so name the language. Ask the voice which languages it has: the `languages` field of `GET /voices/{id}`. A language the voice does not speak returns `voice_language_not_available`. Providers without a notion of language ignore the field."
     },
     "settings": {
      "$ref": "#/components/schemas/SpeechSettings"
     },
     "idempotency_key": {
      "type": "string",
      "maxLength": 128,
      "description": "Same meaning as the `Idempotency-Key` header. If both are sent they must match."
     }
    },
    "additionalProperties": false
   },
   "SpeechAccepted": {
    "type": "object",
    "title": "SpeechAccepted",
    "required": [
     "id",
     "status",
     "characters",
     "queue_position"
    ],
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "description": "Poll this id at `GET /speech/{id}`."
     },
     "status": {
      "$ref": "#/components/schemas/JobStatus"
     },
     "characters": {
      "type": "integer",
      "description": "Characters reserved for this job. A cancelled or failed job releases the reservation."
     },
     "queue_position": {
      "type": "integer",
      "description": "Position in the owner's queue at the moment of the answer. 0 when the job is not queued."
     },
     "warnings": {
      "type": "array",
      "description": "Present only when the text markup had to be adapted for the target provider. Absent on an idempotent replay: the text was accepted and priced the first time.",
      "items": {
       "$ref": "#/components/schemas/MarkupWarning"
      }
     }
    }
   },
   "Job": {
    "type": "object",
    "title": "Job",
    "required": [
     "id",
     "status",
     "progress",
     "characters",
     "created_at"
    ],
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid"
     },
     "status": {
      "$ref": "#/components/schemas/JobStatus"
     },
     "progress": {
      "type": "integer",
      "minimum": 0,
      "maximum": 100,
      "description": "Capped at 99 until the job is completed: 100 means exactly one thing — the audio exists."
     },
     "characters": {
      "type": "integer",
      "description": "Characters reserved for the job."
     },
     "credits_charged": {
      "type": [
       "integer",
       "null"
      ],
      "description": "Characters actually charged. `null` while the job is unfinished — the money is still a reservation."
     },
     "provider": {
      "type": [
       "string",
       "null"
      ],
      "description": "Which provider rendered the job."
     },
     "language": {
      "type": [
       "string",
       "null"
      ],
      "description": "The language actually sung — a fact from the provider, not the request. A provider with no matching clone may fall back to the voice's base language, and this field says so."
     },
     "voice_id": {
      "type": [
       "string",
       "null"
      ]
     },
     "elevenlabs_voice_id": {
      "type": [
       "string",
       "null"
      ]
     },
     "voice_engine": {
      "type": [
       "string",
       "null"
      ],
      "description": "`null` where the provider has no notion of an engine."
     },
     "audio_url": {
      "type": [
       "string",
       "null"
      ],
      "format": "uri",
      "description": "Present only when `status` is `completed`. Short-lived (about six hours) — ask for the job again to get a fresh link."
     },
     "duration_seconds": {
      "type": [
       "number",
       "null"
      ]
     },
     "error": {
      "type": [
       "string",
       "null"
      ],
      "description": "The raw internal failure code, kept for backward compatibility. Prefer `error_code`."
     },
     "error_code": {
      "type": [
       "string",
       "null"
      ],
      "description": "Stable public failure code, present when `status` is `failed`: `queue_timeout`, `execution_timeout`, `provider_unavailable`, `voice_unavailable`, `synthesis_failed`, `assembly_failed`, `output_too_large`, or `generation_failed` for anything else. Reserved credits are refunded in every one of these cases."
     },
     "error_message": {
      "type": [
       "string",
       "null"
      ],
      "description": "English explanation of `error_code`, safe to show to a person."
     },
     "created_at": {
      "type": "string",
      "format": "date-time"
     },
     "started_at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "finished_at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     },
     "poll_after_seconds": {
      "type": [
       "integer",
       "null"
      ],
      "description": "How many seconds to wait before asking about this job again. 10 while `queued`, 5 while `processing`, 2 once the job is nearly done or is being assembled, and `null` on a terminal status — `null` means stop polling, it is not a zero-second delay. Prefer this value over a fixed interval: it is the server telling you how often it is worth asking.",
      "examples": [
       5
      ]
     }
    }
   },
   "JobListItem": {
    "type": "object",
    "title": "JobListItem",
    "required": [
     "id",
     "status",
     "progress",
     "characters",
     "created_at"
    ],
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid"
     },
     "status": {
      "$ref": "#/components/schemas/JobStatus"
     },
     "progress": {
      "type": "integer",
      "minimum": 0,
      "maximum": 100
     },
     "characters": {
      "type": "integer"
     },
     "provider": {
      "type": [
       "string",
       "null"
      ]
     },
     "created_at": {
      "type": "string",
      "format": "date-time"
     },
     "finished_at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     }
    }
   },
   "JobPage": {
    "type": "object",
    "title": "JobPage",
    "required": [
     "items",
     "next_cursor"
    ],
    "properties": {
     "items": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/JobListItem"
      }
     },
     "next_cursor": {
      "type": [
       "string",
       "null"
      ],
      "description": "Pass as `cursor` for the next page. `null` on the last page."
     }
    }
   },
   "Voice": {
    "type": "object",
    "title": "Voice",
    "required": [
     "provider",
     "id"
    ],
    "properties": {
     "provider": {
      "type": "string",
      "description": "Which provider synthesizes this voice. Needed to choose deliberately: providers differ in what they support."
     },
     "id": {
      "type": "string",
      "description": "Canonical voice id — pass it as `voice_id` in `POST /speech`."
     },
     "name": {
      "type": [
       "string",
       "null"
      ]
     },
     "language": {
      "type": [
       "string",
       "null"
      ],
      "description": "The speaker's base language."
     },
     "languages": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "string"
      },
      "description": "The whole language stack. `null` when the source does not know it — then `language` applies."
     },
     "gender": {
      "type": [
       "string",
       "null"
      ]
     },
     "age": {
      "type": [
       "string",
       "null"
      ]
     },
     "accent": {
      "type": [
       "string",
       "null"
      ]
     },
     "description": {
      "type": [
       "string",
       "null"
      ]
     },
     "tags": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "string"
      },
      "description": "Provider labels. `null` where the provider has none — not an empty list."
     },
     "engines": {
      "type": [
       "array",
       "null"
      ],
      "items": {
       "type": "string"
      },
      "description": "Synthesis engines this voice supports. `null` where the provider has no notion of an engine."
     },
     "default_engine": {
      "type": [
       "string",
       "null"
      ]
     },
     "elevenlabs_voice_id": {
      "type": [
       "string",
       "null"
      ],
      "description": "The same voice in the external catalog the first provider is built on, when it is there."
     },
     "preview_url": {
      "type": [
       "string",
       "null"
      ],
      "format": "uri"
     }
    }
   },
   "VoicePage": {
    "type": "object",
    "title": "VoicePage",
    "required": [
     "total",
     "limit",
     "offset",
     "voices"
    ],
    "properties": {
     "total": {
      "type": "integer",
      "description": "How many voices match the filters across the whole allowed catalog."
     },
     "limit": {
      "type": "integer",
      "description": "The page size actually applied."
     },
     "offset": {
      "type": "integer",
      "description": "The offset actually applied."
     },
     "voices": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Voice"
      }
     }
    }
   },
   "LimitOrigin": {
    "type": "string",
    "enum": [
     "key",
     "plan",
     "default"
    ],
    "description": "`key` — set on this key; `plan` — inherited from the owner's plan; `default` — a service value that exists in neither."
   },
   "Account": {
    "type": "object",
    "title": "Account",
    "required": [
     "credits",
     "limits",
     "limit_sources",
     "key",
     "usage"
    ],
    "properties": {
     "credits": {
      "type": "object",
      "required": [
       "api_spendable_chars",
       "available_chars",
       "balance_chars",
       "reserved_chars"
      ],
      "properties": {
       "api_spendable_chars": {
        "type": "integer",
        "description": "What the API can actually spend: purchased characters minus what is reserved. Subscriptions and bonuses do not pay for the API."
       },
       "available_chars": {
        "type": "integer"
       },
       "balance_chars": {
        "type": "integer"
       },
       "reserved_chars": {
        "type": "integer"
       },
       "bonus_available": {
        "type": "integer"
       },
       "purchased_available": {
        "type": "integer"
       }
      }
     },
     "limits": {
      "type": "object",
      "description": "Effective limits: the owner's plan with the key's own numbers laid over it.",
      "properties": {
       "max_chars_per_request": {
        "type": "integer"
       },
       "concurrency": {
        "type": "integer"
       },
       "active_slots": {
        "type": "integer"
       },
       "queue_limit": {
        "type": "integer"
       },
       "thread_count": {
        "type": "integer"
       },
       "priority_weight": {
        "type": "integer"
       },
       "rate_per_min": {
        "type": "integer"
       },
       "rate_burst": {
        "type": "integer"
       },
       "daily_chars_limit": {
        "type": [
         "integer",
         "null"
        ],
        "description": "`null` — no volume cap."
       },
       "monthly_chars_limit": {
        "type": [
         "integer",
         "null"
        ],
        "description": "`null` — no volume cap."
       },
       "allowed_providers": {
        "type": "array",
        "items": {
         "type": "string"
        },
        "description": "Which providers may serve this key. ORDER MATTERS: it is the client's priority, and the first available one takes the job."
       },
       "clone_limit": {
        "type": [
         "integer",
         "null"
        ],
        "description": "`null` — unlimited; `0` — cloning is not allowed for this key."
       }
      }
     },
     "limit_sources": {
      "type": "object",
      "description": "Where every number in `limits` came from.",
      "additionalProperties": {
       "$ref": "#/components/schemas/LimitOrigin"
      }
     },
     "key": {
      "type": "object",
      "properties": {
       "id": {
        "type": "string",
        "format": "uuid"
       },
       "tier": {
        "type": "string",
        "enum": [
         "standard",
         "ultra"
        ],
        "description": "The tier of the KEY, not of the owner's subscription."
       },
       "dedicated_lane": {
        "type": "boolean",
        "description": "`true` when the key has an execution lane of its own on top of the one shared with the web app."
       },
       "scopes": {
        "type": "array",
        "items": {
         "type": "string",
         "enum": [
          "speech",
          "speech:read",
          "voices",
          "account",
          "clone"
         ]
        },
        "description": "The rights of this key. The factory set is `speech`, `voices`, `account`."
       },
       "expires_at": {
        "type": [
         "string",
         "null"
        ],
        "format": "date-time",
        "description": "`null` — the key never expires."
       },
       "key_pool": {
        "type": "string",
        "description": "The capacity pool that serves this key."
       },
       "spend_bucket": {
        "type": "string",
        "enum": [
         "purchased",
         "bonus_then_purchased",
         "plan_first"
        ],
        "description": "What pays for this key's jobs."
       }
      }
     },
     "usage": {
      "type": "object",
      "properties": {
       "active_jobs": {
        "type": "integer",
        "description": "Whole account, including the web app and other keys."
       },
       "queued_jobs": {
        "type": "integer",
        "description": "Whole account, including the web app and other keys."
       },
       "key": {
        "type": "object",
        "description": "This key alone.",
        "properties": {
         "active_jobs": {
          "type": "integer"
         },
         "queued_jobs": {
          "type": "integer"
         },
         "chars_today": {
          "type": "integer"
         },
         "chars_this_month": {
          "type": "integer"
         },
         "daily_chars_remaining": {
          "type": [
           "integer",
           "null"
          ],
          "description": "`null` — no cap, which is not the same as zero left."
         },
         "monthly_chars_remaining": {
          "type": [
           "integer",
           "null"
          ]
         },
         "daily_resets_at": {
          "type": [
           "string",
           "null"
          ],
          "format": "date-time",
          "description": "Period boundaries are UTC."
         },
         "monthly_resets_at": {
          "type": [
           "string",
           "null"
          ],
          "format": "date-time"
         }
        }
       }
      }
     }
    }
   },
   "CloneMode": {
    "type": "string",
    "enum": [
     "sample",
     "library"
    ],
    "description": "`sample` — a private clone from your own audio; `library` — a language variant of a catalog voice, whose result is shared with every user of the service."
   },
   "CloneSampleForm": {
    "type": "object",
    "title": "CloneSampleForm",
    "description": "Multipart order. THE TEXT FIELDS MUST BE SENT BEFORE THE FILE. Defaults: a private clone from your own sample.",
    "required": [
     "audio",
     "name",
     "consent"
    ],
    "properties": {
     "audio": {
      "type": "string",
      "format": "binary",
      "description": "The sample: mp3, wav, m4a or webm, up to 15 MB. Checked against the real bytes, not the file name."
     },
     "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 80
     },
     "consent": {
      "type": "string",
      "minLength": 20,
      "maxLength": 500,
      "description": "A statement of your right to this voice. Stored with the request — it is the only record of who claimed the voice."
     },
     "provider": {
      "type": "string",
      "description": "Which provider builds the voice. Defaults to the one that can build a private clone."
     },
     "mode": {
      "$ref": "#/components/schemas/CloneMode"
     },
     "languages": {
      "type": "string",
      "description": "Comma-separated BCP 47 tags, ten at most (`en,de`)."
     },
     "acknowledge_shared": {
      "type": "string",
      "description": "`true` to confirm that the result will be shared. Required only for `mode=library`."
     }
    }
   },
   "CloneLibraryRequest": {
    "type": "object",
    "title": "CloneLibraryRequest",
    "description": "JSON order of a language variant of a catalog voice. A link to an audio file is deliberately not accepted here. Defaults: `mode=library`.",
    "required": [
     "name",
     "consent",
     "elevenlabs_voice_id",
     "languages",
     "acknowledge_shared"
    ],
    "properties": {
     "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 80
     },
     "consent": {
      "type": "string",
      "minLength": 20,
      "maxLength": 500
     },
     "provider": {
      "type": "string",
      "description": "Which provider builds the voice. Defaults to the one that can build language variants."
     },
     "mode": {
      "$ref": "#/components/schemas/CloneMode"
     },
     "languages": {
      "type": "array",
      "maxItems": 10,
      "items": {
       "type": "string",
       "maxLength": 35,
       "pattern": "^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
      },
      "description": "BCP 47 tags of the languages to build."
     },
     "elevenlabs_voice_id": {
      "type": "string",
      "maxLength": 200,
      "description": "Identifier of the source catalog voice. Required for `mode=library`. `external_id` is accepted as a synonym."
     },
     "external_id": {
      "type": "string",
      "maxLength": 200,
      "description": "Synonym of `elevenlabs_voice_id`."
     },
     "acknowledge_shared": {
      "type": "boolean",
      "description": "Confirmation that the built voice will be visible to every user of the service and cannot be deleted. Required for `mode=library`."
     }
    },
    "additionalProperties": false
   },
   "CloneJob": {
    "type": "object",
    "title": "CloneJob",
    "required": [
     "id",
     "status",
     "provider",
     "mode",
     "name",
     "visibility",
     "percent",
     "poll_after_seconds",
     "characters",
     "created_at"
    ],
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid"
     },
     "status": {
      "$ref": "#/components/schemas/JobStatus"
     },
     "provider": {
      "type": "string"
     },
     "mode": {
      "$ref": "#/components/schemas/CloneMode"
     },
     "name": {
      "type": "string"
     },
     "visibility": {
      "type": "string",
      "enum": [
       "private",
       "shared"
      ],
      "description": "`private` — the voice is yours; `shared` — every user of the service sees it and it cannot be deleted."
     },
     "voice_id": {
      "type": [
       "string",
       "null"
      ],
      "description": "What to pass to `POST /speech`. Filled in once `status` is `completed`."
     },
     "voice_key": {
      "type": [
       "string",
       "null"
      ]
     },
     "languages": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "percent": {
      "type": "integer",
      "minimum": 0,
      "maximum": 100,
      "description": "Capped at 99 until the voice is ready — 100 means exactly one thing."
     },
     "eta_seconds": {
      "type": [
       "integer",
       "null"
      ]
     },
     "poll_after_seconds": {
      "type": "integer",
      "description": "How often to poll this request. Polling faster achieves nothing."
     },
     "characters": {
      "type": "integer",
      "description": "What the order cost in characters."
     },
     "deleted": {
      "type": "boolean",
      "description": "The voice of this request has already been deleted; the request itself remains as a record."
     },
     "error": {
      "type": [
       "string",
       "null"
      ],
      "description": "Terminal failure code when `status` is `failed`: `clone_provider_unavailable`, `clone_sample_lost`, `clone_interrupted`, `clone_failed`, `clone_provider_mismatch`, `build_failed`. Retrying is worth it only where the order provably never reached the provider; after `clone_interrupted` check your voice list first."
     },
     "error_message": {
      "type": [
       "string",
       "null"
      ]
     },
     "created_at": {
      "type": "string",
      "format": "date-time"
     },
     "finished_at": {
      "type": [
       "string",
       "null"
      ],
      "format": "date-time"
     }
    }
   },
   "CloneJobPage": {
    "type": "object",
    "title": "CloneJobPage",
    "required": [
     "items",
     "next_cursor"
    ],
    "properties": {
     "items": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/CloneJob"
      }
     },
     "next_cursor": {
      "type": [
       "string",
       "null"
      ]
     }
    }
   },
   "WebhookEnvelope": {
    "type": "object",
    "required": [
     "id",
     "schema_version",
     "event",
     "object_id",
     "sent_at",
     "data"
    ],
    "description": "Every delivery has the same envelope; only `data` differs per event.",
    "properties": {
     "id": {
      "type": "string",
      "format": "uuid",
      "description": "Delivery id. Repeated with `X-Tonvio-Delivery` — deduplicate on it: delivery is at-least-once."
     },
     "schema_version": {
      "type": "integer",
      "const": 1,
      "description": "Envelope version. A new field never bumps it; a breaking change does."
     },
     "event": {
      "type": "string",
      "enum": [
       "speech.completed",
       "speech.failed",
       "clone.completed",
       "clone.failed"
      ]
     },
     "object_id": {
      "type": "string",
      "description": "Id of the job or clone request the event is about."
     },
     "sent_at": {
      "type": "string",
      "format": "date-time",
      "description": "When the EVENT happened — not when this attempt was made. The attempt's time is in the signature (`t=`)."
     },
     "data": {
      "type": "object",
      "description": "A subset of the matching polling response: for `speech.*` the job card of `GET /speech/{id}` (without `error_code`/`error_message`), for `clone.*` the body of `GET /voice-clones/{id}`.",
      "additionalProperties": true
     }
    }
   }
  }
 },
 "webhooks": {
  "speech.completed": {
   "post": {
    "operationId": "webhook_speech_completed",
    "summary": "Synthesis finished",
    "description": "Sent when a job created with this key completes.\n\n**The `audio_url` in the body is short-lived (about six hours), and the retry window is shorter than that but close to it.** Treat the event as a signal, not as storage: fetch `GET /speech/{id}` for a fresh link if you did not download it right away.\n\nDelivery is **at-least-once**: deduplicate on `id` (`X-Tonvio-Delivery`). Answer 2xx as soon as you have stored the event — do the work afterwards. Failures are retried 5 times with a 30s / 5m / 30m / 2h backoff, roughly a 2.6-hour window; `410 Gone` stops them immediately. Redirects are NOT followed, and the receiver must be a public HTTPS address — private ranges and localhost are refused by design.",
    "parameters": [
     {
      "name": "X-Tonvio-Event",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Event name, same as in the body."
     },
     {
      "name": "X-Tonvio-Delivery",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Delivery id — the deduplication key."
     },
     {
      "name": "X-Tonvio-Attempt",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Attempt number, starting at 1."
     },
     {
      "name": "X-Tonvio-Timestamp",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Unix time of this attempt; also the `t` of the signature."
     },
     {
      "name": "X-Tonvio-Signature",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "`t=<unix>,v1=<hex>`, where the hex is HMAC-SHA256 over the exact string `<t>.<raw body>` keyed with your webhook secret. Compare in constant time and reject anything older than 5 minutes. Sign the RAW bytes — re-serialising the JSON changes the signature. Absent only if no secret has been issued."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WebhookEnvelope"
       },
       "example": {
        "id": "3f1c...",
        "schema_version": 1,
        "event": "speech.completed",
        "object_id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
        "sent_at": "2026-09-05T09:12:58.000Z",
        "data": {
         "id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
         "status": "completed",
         "progress": 100,
         "characters": 18,
         "credits_charged": 18,
         "audio_url": "https://cdn.tonvio.net/audio/...",
         "duration_seconds": 2.4
        }
       }
      }
     }
    },
    "responses": {
     "2XX": {
      "description": "Any 2xx marks the delivery done. Anything else is retried; `410 Gone` stops retries for good."
     }
    }
   }
  },
  "speech.failed": {
   "post": {
    "operationId": "webhook_speech_failed",
    "summary": "Synthesis failed",
    "description": "Sent when a job created with this key ends in failure. The reservation is returned in full — a job we did not deliver is not charged for.\n\nDelivery is **at-least-once**: deduplicate on `id` (`X-Tonvio-Delivery`). Answer 2xx as soon as you have stored the event — do the work afterwards. Failures are retried 5 times with a 30s / 5m / 30m / 2h backoff, roughly a 2.6-hour window; `410 Gone` stops them immediately. Redirects are NOT followed, and the receiver must be a public HTTPS address — private ranges and localhost are refused by design.",
    "parameters": [
     {
      "name": "X-Tonvio-Event",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Event name, same as in the body."
     },
     {
      "name": "X-Tonvio-Delivery",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Delivery id — the deduplication key."
     },
     {
      "name": "X-Tonvio-Attempt",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Attempt number, starting at 1."
     },
     {
      "name": "X-Tonvio-Timestamp",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Unix time of this attempt; also the `t` of the signature."
     },
     {
      "name": "X-Tonvio-Signature",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "`t=<unix>,v1=<hex>`, where the hex is HMAC-SHA256 over the exact string `<t>.<raw body>` keyed with your webhook secret. Compare in constant time and reject anything older than 5 minutes. Sign the RAW bytes — re-serialising the JSON changes the signature. Absent only if no secret has been issued."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WebhookEnvelope"
       },
       "example": {
        "id": "7a22...",
        "schema_version": 1,
        "event": "speech.failed",
        "object_id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
        "sent_at": "2026-09-05T09:13:10.000Z",
        "data": {
         "id": "b0f2c2f4-2f1a-4a55-9d1e-8b7c6d5e4f30",
         "status": "failed",
         "progress": 0,
         "audio_url": null
        }
       }
      }
     }
    },
    "responses": {
     "2XX": {
      "description": "Any 2xx marks the delivery done. Anything else is retried; `410 Gone` stops retries for good."
     }
    }
   }
  },
  "clone.completed": {
   "post": {
    "operationId": "webhook_clone_completed",
    "summary": "Voice clone is ready",
    "description": "Sent when a clone request created with this key succeeds; `data.voice_id` can be used for synthesis straight away.\n\nDelivery is **at-least-once**: deduplicate on `id` (`X-Tonvio-Delivery`). Answer 2xx as soon as you have stored the event — do the work afterwards. Failures are retried 5 times with a 30s / 5m / 30m / 2h backoff, roughly a 2.6-hour window; `410 Gone` stops them immediately. Redirects are NOT followed, and the receiver must be a public HTTPS address — private ranges and localhost are refused by design.",
    "parameters": [
     {
      "name": "X-Tonvio-Event",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Event name, same as in the body."
     },
     {
      "name": "X-Tonvio-Delivery",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Delivery id — the deduplication key."
     },
     {
      "name": "X-Tonvio-Attempt",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Attempt number, starting at 1."
     },
     {
      "name": "X-Tonvio-Timestamp",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Unix time of this attempt; also the `t` of the signature."
     },
     {
      "name": "X-Tonvio-Signature",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "`t=<unix>,v1=<hex>`, where the hex is HMAC-SHA256 over the exact string `<t>.<raw body>` keyed with your webhook secret. Compare in constant time and reject anything older than 5 minutes. Sign the RAW bytes — re-serialising the JSON changes the signature. Absent only if no secret has been issued."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WebhookEnvelope"
       },
       "example": {
        "id": "91bd...",
        "schema_version": 1,
        "event": "clone.completed",
        "object_id": "c1d2...",
        "sent_at": "2026-09-05T09:40:00.000Z",
        "data": {
         "id": "c1d2...",
         "state": "completed",
         "voice_id": "el_YYYY"
        }
       }
      }
     }
    },
    "responses": {
     "2XX": {
      "description": "Any 2xx marks the delivery done. Anything else is retried; `410 Gone` stops retries for good."
     }
    }
   }
  },
  "clone.failed": {
   "post": {
    "operationId": "webhook_clone_failed",
    "summary": "Voice clone failed",
    "description": "Sent when a clone request created with this key fails. `data.error_code` says why.\n\nDelivery is **at-least-once**: deduplicate on `id` (`X-Tonvio-Delivery`). Answer 2xx as soon as you have stored the event — do the work afterwards. Failures are retried 5 times with a 30s / 5m / 30m / 2h backoff, roughly a 2.6-hour window; `410 Gone` stops them immediately. Redirects are NOT followed, and the receiver must be a public HTTPS address — private ranges and localhost are refused by design.",
    "parameters": [
     {
      "name": "X-Tonvio-Event",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Event name, same as in the body."
     },
     {
      "name": "X-Tonvio-Delivery",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string",
       "format": "uuid"
      },
      "description": "Delivery id — the deduplication key."
     },
     {
      "name": "X-Tonvio-Attempt",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Attempt number, starting at 1."
     },
     {
      "name": "X-Tonvio-Timestamp",
      "in": "header",
      "required": true,
      "schema": {
       "type": "integer"
      },
      "description": "Unix time of this attempt; also the `t` of the signature."
     },
     {
      "name": "X-Tonvio-Signature",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string"
      },
      "description": "`t=<unix>,v1=<hex>`, where the hex is HMAC-SHA256 over the exact string `<t>.<raw body>` keyed with your webhook secret. Compare in constant time and reject anything older than 5 minutes. Sign the RAW bytes — re-serialising the JSON changes the signature. Absent only if no secret has been issued."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WebhookEnvelope"
       },
       "example": {
        "id": "55ef...",
        "schema_version": 1,
        "event": "clone.failed",
        "object_id": "c1d2...",
        "sent_at": "2026-09-05T09:41:00.000Z",
        "data": {
         "id": "c1d2...",
         "state": "failed",
         "error_code": "sample_too_short"
        }
       }
      }
     }
    },
    "responses": {
     "2XX": {
      "description": "Any 2xx marks the delivery done. Anything else is retried; `410 Gone` stops retries for good."
     }
    }
   }
  }
 }
}