# VieNeu API reference

Source: https://docs.vieneu.io/vi/api-reference.md

Generated from the OpenAPI 3.0 specification the server produces from its own
handlers, served as a plain file at https://docs.vieneu.io/vi/openapi.json. The HTML page at
https://docs.vieneu.io/vi/api-reference renders the same document client-side, so its
served markup contains none of the text below.

Base URL: `https://api.vieneu.io`

Spec version: `1.0`

---

## Endpoints (27)

- `POST /api/v1/audio/speech` — OpenAI-compatible speech synthesis
- `GET /api/v1/audio/voices` — List voice ids for the OpenAI-compatible endpoint
- `GET /api/v1/audiobooks` — List your audiobooks, newest first
- `POST /api/v1/audiobooks` — Create an audiobook (narrator and cast)
- `GET /api/v1/audiobooks/{bookId}` — An audiobook with its chapters, progress and download links
- `POST /api/v1/audiobooks/{bookId}/cancel` — Cancel a book
- `POST /api/v1/audiobooks/{bookId}/chapters` — Add a chapter (or more script to a chapter not yet started)
- `POST /api/v1/audiobooks/{bookId}/start` — Start (or resume) rendering a book
- `GET /api/v1/balance` — Tokens this key can spend
- `POST /api/v1/dialogue` — Synthesize a multi-speaker dialogue
- `POST /api/v1/dub` — Dub a Vietnamese audio file with a new voice
- `GET /api/v1/emotion-tags` — List emotion styles and inline cue tags
- `GET /api/v1/engines` — List engines, what each can do, and what each costs
- `POST /api/v1/srt` — Dub an .srt subtitle file into a timecode-aligned WAV
- `GET /api/v1/tts` — Get the status of several jobs in one call
- `POST /api/v1/tts` — Submit a TTS generation job
- `GET /api/v1/tts/{jobId}` — Get job status and audio URL
- `POST /api/v1/tts/stream` — Stream speech synthesis with low latency
- `GET /api/v1/usage` — Usage of this key (or the whole account) over a window
- `POST /api/v1/vapi/speech` — Vapi custom-voice webhook (raw PCM for voice agents)
- `GET /api/v1/voices` — List available voices
- `GET /api/v1/webhooks` — List your webhook endpoints
- `POST /api/v1/webhooks` — Register a webhook endpoint
- `GET /api/v1/webhooks/{endpointId}` — Get one webhook endpoint
- `DELETE /api/v1/webhooks/{endpointId}` — Delete a webhook endpoint
- `GET /api/v1/webhooks/{endpointId}/deliveries` — Recent delivery attempts for an endpoint
- `POST /api/v1/webhooks/{endpointId}/rotate-secret` — Rotate the signing secret

---

## Authentication

- **apiKeyBearer** — `Authorization: Bearer <key>`
  Your VieNeu API key, sent as `Authorization: Bearer vn_sk_…`. This is an API key, not a JWT — do not call /api/auth/login for it.
- **apiKeyHeader** — `X-API-Key: <key>` (header)
  Your VieNeu API key, sent as `X-API-Key: vn_sk_…`. Equivalent to the Authorization header — send one or the other, not both.
- **vapiSecret** — `X-VAPI-SECRET: <key>` (header)
  Vapi's own header. A Vapi assistant cannot set Authorization or X-API-Key, so put your VieNeu API key in the assistant's `server.secret` and Vapi forwards it here. Declared on POST /api/v1/vapi/speech, the only route a Vapi assistant calls.

---

## Shared schemas

### OpenAiErrorResponseDto

- `error` (OpenAiErrorBodyDto, required)
  - `message` (string, required, example `"Missing required parameter: 'input'."`)
  - `type` (string, required, example `"invalid_request_error"`)
  - `param` (string, optional, nullable, example `"input"`)
  - `code` (string, optional, nullable, example `null`)

### PublicApiErrorDto

- `statusCode` (number, optional, example `403`) — HTTP status code, repeated in the body. Always present. It used to be absent from any rejection that carried a `code`; it no longer is. The status line still holds the authoritative copy — this one is a convenience for clients that only keep the parsed body.
- `message` (string | array of string, required, example `"Insufficient tokens. You have 120 tokens remaining but this request costs 450 tokens."`) — What went wrong, in prose. A validation failure (ValidationPipe) returns an ARRAY of messages here instead of a string, one per rejected field — handle both.
- `code` (string, optional, example `"GRANT_DAILY_LIMIT"`) — Machine-readable reason, on the rejections that carry one — and some carry none (see the clone note below), so handle its absence. Branch on this rather than on `message`. Quota, on the six routes that bill a grant and answer in THIS shape (POST /v1/tts, /v1/tts/stream, /v1/dialogue, /v1/dub, /v1/srt, /v1/vapi/speech — POST /v1/audio/speech is billed too but answers OpenAI’s envelope with OpenAI’s own `type` values). A request the key’s own plan cannot pay is paid by another active plan of the account when one can, so these mean NO plan could; with several, the refusal describes the one to act on — the soonest cap reset the request fits under, else the plan that came closest: `GRANT_EXPIRED` (403, no `required`), `GRANT_TOKENS_EXHAUSTED` (403, with `remaining` + `required`), `GRANT_DAILY_LIMIT` and `GRANT_WEEKLY_LIMIT` (429, with `resetAt` + `remaining` + `required`), `GRANT_INACTIVE` (403, with `required` — the plan the key is bound to is used up, expired or replaced, and the account has no other active plan for this engine; until 2026-10-10 this was a bare 403 with no `code`), `CONCURRENT_CONFLICT` (429, transient and carrying no numbers but `retryAfterSeconds: 1` / `Retry-After: 1` — the server already retried several times, so wait that second before you do). `FREE_DAILY_LIMIT` belongs to the in-app free tier and cannot occur here: an API key always bills a grant. Capacity: `QUEUE_FULL` and `USER_QUEUE_FULL` (429, POST /v1/tts, with `retryAfterSeconds`; nothing was charged), `STREAM_CONCURRENCY` (429, too many live streams on one key). Idempotency (POST /v1/tts with an `Idempotency-Key`): `IDEMPOTENCY_KEY_INVALID` (400), `IDEMPOTENCY_IN_FLIGHT` (409, with `retryAfterSeconds` — retry with the SAME key), `IDEMPOTENCY_KEY_REUSED` (422). Cloning: `CLONE_WEB_ONLY` (410) is the only clone code an API key can still meet. Creating a cloned voice moved to the web Studio (https://vieneu.io/#/clone), so the enrolment routes answer that code instead of running — nothing is billed. Voices you cloned there keep working here: they are listed by GET /v1/voices when you send your key and their `clone_…` id is a normal `voiceId` on POST /v1/tts and /v1/tts/stream. A clone that is missing or not yours fails generation with a plain message and no `code`. Treat an unknown value as a plain failure: the list grows.
- `error` (string, optional, example `"Forbidden"`) — Nest’s status name, present only where the rejection was raised as one of Nest’s own exception classes. It is NARROWER than `statusCode`: the common `{ statusCode, message, traceId }` form a raw HttpException produces carries no `error` at all. Never parse it — it is the status word, not a reason.
- `retryAfterSeconds` (number, optional, example `15`) — Seconds to wait before retrying, on the rejections that recover by themselves: `QUEUE_FULL` / `USER_QUEUE_FULL` (15), `CONCURRENT_CONFLICT` (1) and `IDEMPOTENCY_IN_FLIGHT` (409, 1). Also sent as the standard `Retry-After` header, which is the copy a generic HTTP client should honour.
- `resetAt` (string, optional, example `"2026-09-08T00:00:00.000Z"`) — ISO 8601 time the exhausted window rolls over, on the two rejections that recover on their own: `GRANT_DAILY_LIMIT` and `GRANT_WEEKLY_LIMIT`. Sleep until this rather than parsing the timestamp out of `message` — and rather than telling the user to upgrade for something that resets in an hour.
- `remaining` (number, optional, example `0`) — Tokens still available under the limit that rejected the request — the window’s headroom for `GRANT_DAILY_LIMIT` / `GRANT_WEEKLY_LIMIT`, the grant’s balance for `GRANT_TOKENS_EXHAUSTED`. Pair it with `required` to tell a user how short they are.
- `required` (number, optional, example `5000`) — Tokens this request would have cost. It rides the three rejections that actually weighed a price against a limit — `GRANT_TOKENS_EXHAUSTED`, `GRANT_DAILY_LIMIT` and `GRANT_WEEKLY_LIMIT` — and `GRANT_INACTIVE`, which names the price no active plan was there to pay. It is ABSENT from the refusals that never got as far as pricing: `GRANT_EXPIRED`, `CONCURRENT_CONFLICT`, and the capacity 429s (`QUEUE_FULL`, `STREAM_CONCURRENCY`). POST /v1/voices is the exception — every fee rejection there carries it, because the amount is the flat enrolment fee and is known before the grant is consulted at all.
- `traceId` (string, optional, example `"0f7c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b"`) — Correlation id, also returned as the `X-Request-Id` response header. Quote it in a support request — it is the id our logs are keyed by.

### AudiobookResponseDto

- `id` (string, required)
- `title` (string, required)
- `author` (string, required, nullable)
- `status` (string, required, one of: `draft`, `queued`, `rendering`, `completed`, `partial`, `failed`, `cancelled`)
- `narratorVoice` (string, required)
- `cast` (array of AudiobookCastEntryDto, required)
  - `speaker` (string, required, example `"Lan"`) — A character name, as used in `segments[].speaker`.
  - `voiceId` (string, required, example `"Ban Mai"`) — Voice id from GET /v1/voices (engine of the book; not a cloned voice).
- `waitingReason` (string, required, nullable) — gpu_busy: books render only while the GPU fleet is idle; quota: the plan hit a limit, retried automatically; queue_full.
- `error` (string, required, nullable)
- `totals` (AudiobookTotalsDto, required)
  - `chapters` (number, required)
  - `completedChapters` (number, required)
  - `characters` (number, required)
  - `estimatedTokens` (number, required) — Tokens the chapters not yet rendered will cost.
  - `tokensCharged` (number, required)
  - `durationSeconds` (number, required)
- `otherBooksWaiting` (number, required, nullable) — While started: how many other books are waiting too. Books take turns, a chapter each.
- `chapterCharsMax` (number, required) — The longest chapter (in characters) the plan behind this key can render: a chapter is billed in one go, so it must fit the plan's daily or weekly token cap. 60,000 when the plan has no tighter cap.
- `chapters` (array of AudiobookChapterResponseDto, required)
  - `index` (number, required)
  - `title` (string, required)
  - `status` (string, required, one of: `pending`, `rendering`, `rendered`, `mastering`, `completed`, `failed`, `cancelled`)
  - `characters` (number, required)
  - `segments` (number, required)
  - `tokenCost` (number, required, nullable)
  - `progress` (AudiobookChapterProgressDto, required, nullable) — While rendering: parts done / planned (a part ≈ 3 minutes of speech).
  - `durationSeconds` (number, required, nullable)
  - `audioUrl` (string, required, nullable) — Presigned link (24 h) once completed.
  - `audioFormat` (string, required, nullable) — mp3 (wav only if mastering failed).
  - `error` (string, required, nullable)
- `createdAt` (string (date-time), required)
- `completedAt` (string (date-time), required, nullable)

---

## Overview

## Vietnamese text-to-speech over HTTPS

This document covers the **public integration surface** only — everything under
`/api/v1`. The application's own endpoints (dashboard, billing,
administration) are not part of the published contract and are deliberately absent.

**Quick start:**
1. Get an API key at https://vieneu.io/#/developer — sign in, open the Developer page,
   and create one there. Live keys begin `vn_sk_`; test keys begin `vn_test_` and cap
   each request at 100 words. Everything else about the two is identical.
2. Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`.
3. Call `POST /api/v1/audio/speech` for audio bytes in the response, or
   `POST /api/v1/tts` + `GET /api/v1/tts/{jobId}` for long text.

**Billing** is per submitted character (minimum 50), refunded automatically when a
request fails to produce audio. `aiRefine` is off by default here and carries a
surcharge when enabled.

**Rate limits** are applied in two places. The application counts them per API key
and answers with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
and, on a 429, `Retry-After`. In front of it the edge proxy also caps requests and
concurrent connections **per source address**, shared by every key behind that
address; when that cap trips the 429 (or 503) carries none of those headers. So back
off on any 429, and do not assume a bare one means your own key is over its limit.

Quote the `X-Request-Id` of a failing call in any support request.

---

## POST /api/v1/audio/speech

**OpenAI-compatible speech synthesis**

Operation id: `createSpeech`

Drop-in for OpenAI `POST /v1/audio/speech`: point an OpenAI SDK at `https://api.vieneu.io/api/v1` with your VieNeu key and it works unchanged. Returns the audio bytes directly — mp3 by default, like OpenAI; `wav`, `opus` and `pcm` are also available via `response_format`. Set `stream_format` to `audio` or `sse` to receive audio as it is generated instead of waiting for the whole file — that requires `response_format: pcm` or `ulaw`, the only two encodings whose frames concatenate. Maps `input`→text, `voice`→VieNeu voice id (from GET /v1/audio/voices) and `model`→engine. Billed by the submitted character count (minimum 50, × the AI surcharge only if you opt in with `aiRefine: true`) and refunded if synthesis fails. Auth via `Authorization: Bearer <api key>`.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Request body (required)

Content-Type: `application/json`

- `model` (string, optional, example `"tts-1"`) — Selects the engine. OpenAI model names (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`) map to the default engine so an unmodified OpenAI client works; `vieneu-v4` selects the engine explicitly (`vieneu-v3` is refused with 400 since V3 was retired on 2026-09-24). Any other value is accepted and ignored, as this endpoint has always done. A `vieneu-…` name for an engine that does not exist IS rejected — that is a choice of engine, and rendering on a different one would bill at a rate you did not pick. `engine` wins if both are sent.
- `input` (string, required, example `"Xin chào, đây là VieNeu."`) — The text to synthesize (OpenAI: `input`). Required.
- `voice` (string, optional, example `"Ngọc Lan"`) — VieNeu voice id from GET /v1/voices (e.g. "Ngọc Lan"), or its display name (`name`). Omit to use the default voice. (OpenAI voice names like "alloy" are not mapped — pass a VieNeu voice.)
- `response_format` (string, optional, one of: `wav`, `mp3`, `opus`, `pcm`, `ulaw`, default `"mp3"`, example `"mp3"`) — Output format. Defaults to `mp3`, as OpenAI does — a client that omits the field expects mp3, and returning WAV to it produced a file its player refused. `wav`, `opus`, `pcm` and `ulaw` are also supported; `pcm` is raw headerless signed 16-bit little-endian at 24 kHz — the rate OpenAI documents, and what this route defaults to unless `sample_rate` says otherwise — and `ulaw` is raw 8 kHz G.711 for telephony. OpenAI's `aac` and `flac` are not supported and return 400.
- `sample_rate` (number, optional, one of: `8000`, `16000`, `22050`, `24000`, `44100`, `48000`, example `24000`) — VieNeu extension: output sample rate in Hz. Omit for 48000 — except with `pcm`, which defaults to 24000 (the rate OpenAI documents, since an OpenAI client has no field to ask for another and headerless PCM at the wrong rate plays at the wrong speed), and `ulaw`, which is always 8000.
- `speed` (number, optional, default `1`, min 0.25, max 4, example `1`) — Playback rate, applied by a pitch-preserving time-stretch. OpenAI accepts 0.25–4.0 and so does this endpoint, but VieNeu's engine only holds quality across 0.5–2.0, so values outside that are CLAMPED, not rejected — a 400 for a value OpenAI itself considers valid would break the drop-in promise. The native /v1 endpoints validate 0.5–2.0 strictly instead.
- `instructions` (string, optional, example `"Speak in a calm tone."`) — Accepted and ignored. It exists because `gpt-4o-mini-tts` — one of the model names this endpoint accepts — is the model whose whole point is this parameter, and the global validation pipe rejects unknown fields. Sending it must not 400; VieNeu conveys style through `emotion` and inline cues.
- `stream_format` (string, optional, one of: `audio`, `sse`, example `"audio"`) — Stream the response instead of buffering the whole file. `audio` sends the encoded bytes as they are produced (chunked transfer). `sse` sends OpenAI-shaped Server-Sent Events: `speech.audio.delta` carrying base64 audio, then a final `speech.audio.done`. That final event is the only positive proof the stream finished — without it, treat the audio as truncated. ONLY valid with `response_format: pcm` or `ulaw`: streamed frames are encoded independently, so mp3/opus/wav cannot be concatenated into one playable file and are rejected with 400. Note `response_format` defaults to mp3, so streaming requires setting it explicitly.
- `emotion` (string, optional, example `"natural"`) — VieNeu extension: speaking style — 'natural' or 'storytelling'.
- `aiRefine` (boolean, optional, default `false`, example `false`) — VieNeu extension — AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way.
- `engine` (string, optional, one of: `v4`, example `"v4"`) — VieNeu extension: TTS engine — "v4" (48 kHz), billed at its own multiplier. "v3" was retired on 2026-09-24 and is refused. Omit for the configured default; GET /v1/engines reports which one that is (`isDefault`).

### Responses

**200** — The audio bytes. Content-Type follows `response_format` (mp3 by default). With `stream_format: "sse"` the body is instead a `text/event-stream` of base64 audio deltas; with `"audio"` it is the same bytes, chunked. For the headerless `pcm` and `ulaw` formats the sample rate is in `X-Sample-Rate` (`pcm` defaults to 24 kHz here, not the engine native rate).

Content-Type: `audio/mpeg`, `audio/wav`, `audio/ogg`, `audio/basic`, `application/octet-stream`, `text/event-stream` — raw binary body

**400** — OpenAI-shaped error: missing `input`, unknown voice, unsupported format.

Content-Type: `application/json`

Schema: `OpenAiErrorResponseDto` — fields under [Shared schemas](#shared-schemas).

**401** — OpenAI-shaped error: API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `OpenAiErrorResponseDto` — fields under [Shared schemas](#shared-schemas).

**403** — OpenAI-shaped error: the grant is out of tokens, has expired, or the plan does not allow the requested engine. A daily/weekly cap comes back as 429 instead.

Content-Type: `application/json`

Schema: `OpenAiErrorResponseDto` — fields under [Shared schemas](#shared-schemas).

**429** — OpenAI-shaped error: rate limit or token quota. See `Retry-After`.

Content-Type: `application/json`

Schema: `OpenAiErrorResponseDto` — fields under [Shared schemas](#shared-schemas).

**503** — OpenAI-shaped error: no worker available for the requested engine or format. Retry shortly.

Content-Type: `application/json`

Schema: `OpenAiErrorResponseDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/audio/voices

**List voice ids for the OpenAI-compatible endpoint**

Operation id: `listOpenAiVoices`

The companion to POST /v1/audio/speech. OpenAI has no such endpoint — its six voice names are a constant — but a VieNeu voice id is not guessable, so the OpenAI-compatible clients that support a custom base URL look for this route to populate their voice picker. Returns a bare `{ voices: [...] }` list of ids, which is the shape those clients expect. GET /v1/voices is the richer version (names, gender, region, your own cloned voices).

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `engine` (query, string, optional, one of: `v4`) — Return only the voices available on this engine.

### Responses

**200** — Bare voice ids, in the shape OpenAI-compatible clients expect.

Content-Type: `application/json`

- `voices` (array of string, required, example `["Ngọc Lan","Trúc Ly"]`) — Bare voice ids, the shape OpenAI-compatible clients expect.

**400** — `engine` is not a known engine.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/audiobooks

**List your audiobooks, newest first**

Operation id: `listAudiobooks`

A summary of each book (no download links; GET one book for those).

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `limit` (query, integer, optional, default `20`, min 1, max 50)
- `page` (query, integer, optional, default `1`, min 1)

### Responses

**200** — One page of books.

Content-Type: `application/json`

- `items` (array of AudiobookResponseDto, required)
  - `id` (string, required)
  - `title` (string, required)
  - `author` (string, required, nullable)
  - `status` (string, required, one of: `draft`, `queued`, `rendering`, `completed`, `partial`, `failed`, `cancelled`)
  - `narratorVoice` (string, required)
  - `cast` (array of AudiobookCastEntryDto, required)
    - `speaker` (string, required, example `"Lan"`) — A character name, as used in `segments[].speaker`.
    - `voiceId` (string, required, example `"Ban Mai"`) — Voice id from GET /v1/voices (engine of the book; not a cloned voice).
  - `waitingReason` (string, required, nullable) — gpu_busy: books render only while the GPU fleet is idle; quota: the plan hit a limit, retried automatically; queue_full.
  - `error` (string, required, nullable)
  - `totals` (AudiobookTotalsDto, required)
    - `chapters` (number, required)
    - `completedChapters` (number, required)
    - `characters` (number, required)
    - `estimatedTokens` (number, required) — Tokens the chapters not yet rendered will cost.
    - `tokensCharged` (number, required)
    - `durationSeconds` (number, required)
  - `otherBooksWaiting` (number, required, nullable) — While started: how many other books are waiting too. Books take turns, a chapter each.
  - `chapterCharsMax` (number, required) — The longest chapter (in characters) the plan behind this key can render: a chapter is billed in one go, so it must fit the plan's daily or weekly token cap. 60,000 when the plan has no tighter cap.
  - `chapters` (array of AudiobookChapterResponseDto, required)
    - `index` (number, required)
    - `title` (string, required)
    - `status` (string, required, one of: `pending`, `rendering`, `rendered`, `mastering`, `completed`, `failed`, `cancelled`)
    - `characters` (number, required)
    - `segments` (number, required)
    - `tokenCost` (number, required, nullable)
    - `progress` (AudiobookChapterProgressDto, required, nullable) — While rendering: parts done / planned (a part ≈ 3 minutes of speech).
    - `durationSeconds` (number, required, nullable)
    - `audioUrl` (string, required, nullable) — Presigned link (24 h) once completed.
    - `audioFormat` (string, required, nullable) — mp3 (wav only if mastering failed).
    - `error` (string, required, nullable)
  - `createdAt` (string (date-time), required)
  - `completedAt` (string (date-time), required, nullable)
- `page` (number, required)
- `limit` (number, required)

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/audiobooks

**Create an audiobook (narrator and cast)**

Operation id: `createAudiobook`

Creates a draft book. Add chapters with POST /v1/audiobooks/{bookId}/chapters, then start it. Voices are catalogue voices of the default engine (cloned voices are not supported for audiobooks yet). LIVE keys only. Nothing is billed until chapters render.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Request body (required)

Content-Type: `application/json`

- `title` (string, required, maxLength 200, example `"Dế Mèn phiêu lưu ký"`)
- `author` (string, optional, maxLength 120, example `"Tô Hoài"`)
- `narratorVoice` (string, required, example `"Anh Thư"`) — The narrator: reads titles and every segment without a character.
- `cast` (array of AudiobookCastEntryDto, optional) — Character voices (up to 30). More can be added with each chapter.
  - `speaker` (string, required, example `"Lan"`) — A character name, as used in `segments[].speaker`.
  - `voiceId` (string, required, example `"Ban Mai"`) — Voice id from GET /v1/voices (engine of the book; not a cloned voice).

### Responses

**201** — The draft book.

Content-Type: `application/json`

Schema: `AudiobookResponseDto` — fields under [Shared schemas](#shared-schemas).

**400** — Unknown voice, too many characters, invalid body.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/audiobooks/{bookId}

**An audiobook with its chapters, progress and download links**

Operation id: `getAudiobook`

Completed chapters carry a presigned `audioUrl` (24 h). A rendering chapter shows `progress` in parts (≈ 3 minutes of speech each). `waitingReason: gpu_busy` means the book is waiting for the GPU fleet to be idle.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `bookId` (path, string, required) — The id returned by createAudiobook.

### Responses

**200** — The book.

Content-Type: `application/json`

Schema: `AudiobookResponseDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**404** — No such book on this account.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/audiobooks/{bookId}/cancel

**Cancel a book**

Operation id: `cancelAudiobook`

Chapters not started are dropped and never billed; a chapter already rendering finishes and is kept.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `bookId` (path, string, required)

### Responses

**200** — The cancelled book.

Content-Type: `application/json`

Schema: `AudiobookResponseDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/audiobooks/{bookId}/chapters

**Add a chapter (or more script to a chapter not yet started)**

Operation id: `addAudiobookChapter`

Segments are read in order: narration by the narrator, lines by the character named in `speaker` (add new characters in `cast`). The title is read first, then a short beat; a `sceneBreak` segment gets a longer silence. Up to 60,000 characters per chapter, or `chapterCharsMax` when the plan behind this key has a tighter daily or weekly cap. A long chapter is sent in several requests with `appendTo` and `afterSegment`.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `bookId` (path, string, required)

### Request body (required)

Content-Type: `application/json`

- `title` (string, optional, example `"Chương 1: Tôi sống độc lập từ thuở bé"`) — Read by the narrator, then a short beat.
- `segments` (array of AudiobookSegmentDto, required) — The chapter in reading order.
  - `speaker` (string, optional, example `"Lan"`) — A character from the cast; empty or "narrator" for the narrator.
  - `text` (string, required, example `"— Mẹ ơi, con đi học đây!"`) — What is read. A paragraph of narration, or one character line. Longer passages are cut at sentence ends.
  - `sceneBreak` (boolean, optional) — This segment starts a new scene (a longer silence before it).
- `cast` (array of AudiobookCastEntryDto, optional) — Characters introduced in this chapter.
  - `speaker` (string, required, example `"Lan"`) — A character name, as used in `segments[].speaker`.
  - `voiceId` (string, required, example `"Ban Mai"`) — Voice id from GET /v1/voices (engine of the book; not a cloned voice).
- `appendTo` (number, optional) — Append these segments to an existing chapter that has not started (by index) instead of adding a new one — for chapters too long for one request.
- `afterSegment` (number, optional) — With `appendTo`: how many segments the chapter has before these (the `segments` count of that chapter in the previous answer). A different count answers 409 `segment_mismatch` instead of adding, so a retried request cannot add its text twice and a lost one cannot leave a gap. Without it, a request repeating the chapter's last segments exactly answers 409 `duplicate_segments`.

### Responses

**201** — The book with the chapter added.

Content-Type: `application/json`

Schema: `AudiobookResponseDto` — fields under [Shared schemas](#shared-schemas).

**400** — A speaker without a voice, limits exceeded, or a chapter longer than the plan can render (`chapter_over_plan_limit`).

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**409** — Not added: the chapter has started, `afterSegment` does not match (`segment_mismatch`), or the request repeats what was just added (`duplicate_segments`, `duplicate_chapter`) — a retried request.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/audiobooks/{bookId}/start

**Start (or resume) rendering a book**

Operation id: `startAudiobook`

Queues every pending or failed chapter. Each chapter is billed in full to ONE plan — this key’s own when it can, otherwise another active plan of the account — so it is refused with 402 `insufficient_tokens` when the plans hold too little for the chapters in total, or when some chapter would find no plan able to pay it when its turn comes. Chapters then render one at a time, only while the GPU fleet is idle, each billed when it starts and refunded if it fails.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `bookId` (path, string, required)

### Responses

**200** — The book, queued.

Content-Type: `application/json`

Schema: `AudiobookResponseDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**402** — Not enough tokens for the chapters to render. For the total, `required` is the book’s estimate and `remaining` what the plans hold together; for one chapter, the message names it, `required` is its estimate and `remaining` the most any one plan would have left when its turn came.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/balance

**Tokens this key can spend**

Operation id: `getBalance`

`remaining` is the largest request the key can pay for right now. A request is paid by ONE plan — the key’s own plan when it can, otherwise another active plan of the account — so this is the most room any single plan has (the lowest of its total, daily and weekly caps), never a sum. `plan` details the plan that number is measured on. `spendableTokens` is the sum: what all the plans that may pay can still spend, request after request — the running total, which drops by each request’s cost. `totalRemainingTokens` sums every active plan on the account. The cost of a request is `characters (minimum 50) × engine billingMultiplier × globalMultiplier` — see GET /v1/engines.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Responses

**200** — What this key can spend, and the plan that measures it.

Content-Type: `application/json`

- `remaining` (number, required, example `15000`) — The largest request this key can pay for right now. A request is paid by ONE plan: the key’s own plan when it can, otherwise another active plan of the account that covers the default engine. This is the most room any single one of them has (the lowest of its total, daily and weekly caps) — never a sum. A request costing more is refused with 403 (or 429 for a daily/weekly cap).
- `spendableTokens` (number, required, example `20000`) — What this key can spend in total right now, request after request: the room left on every plan that may pay its requests (its own plan, then the account’s other active plans that cover the default engine), each counted only up to its own daily and weekly caps, added up. Each request lowers it by what it cost, whichever of those plans paid. Equal to `remaining` when only one plan can pay. One request can still spend no more than `remaining`.
- `plan` (PublicBalancePlanDto, optional, nullable) — The plan (token grant) `remaining` is measured on — the one with the most room. When no plan can pay, the plan this key is bound to, which explains why.
- `activeGrants` (number, required, example `2`) — Active plans on the account. When this key’s own plan cannot pay for a request, another one pays for it.
- `totalRemainingTokens` (number, required, example `9984210`) — Remaining tokens summed over every active plan of the account.

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/dialogue

**Synthesize a multi-speaker dialogue**

Operation id: `createDialogue`

Synthesizes an ordered list of turns (1–50), each with its own voice and emotion, into a single concatenated WAV. Tokens are deducted upfront, billed by the submitted character count (minimum 50, × the AI surcharge only when you opt in with `aiRefine: true`), and refunded if synthesis fails. With `aiRefine: true` the dialogue is moderated and each turn is pronunciation-normalized (formulas, acronyms, mixed English), billed with the surcharge; by default (`false`) turns are synthesized as provided — no content check, no normalization, no surcharge. Pass `engine` to choose the TTS engine (each is billed at its own multiplier); omit it for the configured default, which GET /v1/engines reports as `isDefault`. Every turn’s voice must belong to the engine that runs. Returns a presigned `audioUrl`.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Request body (required)

Content-Type: `application/json`

- `turns` (array of PublicDialogueTurnDto, required) — Ordered list of dialogue turns (1–50).
  - `speaker` (string, required, example `"Host"`) — Speaker label (display/metadata only).
  - `voiceId` (string, required, example `"Ngọc Lan"`) — Preset voice ID from GET /v1/voices, or its display name (`name`).
  - `text` (string, required, maxLength 5000, example `"Xin chào, hôm nay bạn thế nào?"`) — Vietnamese text for this turn (max 5000 chars).
  - `emotion` (string, optional, one of: `natural`, `storytelling`, `tin_tuc`, `tu_nhien`, `doc_truyen`, `emotion_0`, `emotion_1`, `emotion_2`, `emotion_3`, `emotion_4`, `emotion_5`, `emotion_6`, `emotion_7`, `emotion_8`, `emotion_9`, `emotion_10`, `emotion_11`, `emotion_12`, `emotion_13`, `emotion_14`, `emotion_15`, `emotion_16`, `emotion_17`, `emotion_18`, `emotion_19`, default `"natural"`, example `"natural"`)
- `pauseBetweenTurnsMs` (number, optional, default `500`, example `500`) — Silence gap between turns (ms, 0–5000).
- `temperature` (number, optional, default `1`, example `1`) — Sampling temperature applied to all turns (0.0–2.0).
- `aiRefine` (boolean, optional, default `false`, example `false`) — AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way. Applies across every turn.
- `engine` (string, optional, one of: `v4`, example `"v4"`) — TTS engine — each is billed at its own multiplier. Every turn's voice must belong to this engine (see GET /v1/voices?engine=…). Omit to use the configured default; GET /v1/engines names it as `isDefault` and lists the live multipliers.

### Responses

**201** — The concatenated dialogue, as a presigned WAV URL.

Content-Type: `application/json`

- `success` (boolean, required, example `true`)
- `turnsCount` (number, required, example `4`) — Number of turns synthesized.
- `speakers` (array of string, required, example `["Host","Guest"]`) — Unique speaker labels in order of first appearance.
- `audioUrl` (string, required, example `"https://s3.amazonaws.com/bucket/dialogue/job.wav?X-Amz-Expires=3600&..."`) — Presigned S3 download URL for the concatenated WAV.
- `audioUrlExpiresIn` (number, required, example `3600`) — Seconds until audioUrl expires.
- `duration` (number, required, example `12.4`) — Duration of the concatenated audio (seconds).
- `tokenCost` (number, required, example `120`) — Tokens deducted for this request.

**400** — Empty or over-long turn list, an unknown voice, or a voice from another engine.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**403** — Your plan does not include dialogue or this engine, or the grant is out of tokens / expired.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**422** — Content refused by moderation. Only reachable with `aiRefine: true`.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/dub

**Dub a Vietnamese audio file with a new voice**

Operation id: `createDub`

Transcribes the uploaded Vietnamese audio (Whisper STT), then re-synthesizes it with the chosen preset voice. Tokens are billed by the OUTPUT audio duration (same rate as the app, × the engine multiplier), charged only on success. Max file size: 10 MB.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Request body (required)

Vietnamese audio file plus the target voice.

Content-Type: `multipart/form-data`

- `audio` (string (binary), required)
- `voiceId` (string, required, example `"Ngọc Lan"`)
- `emotion` (string, optional, example `"natural"`)
- `temperature` (number, optional, example `1`)
- `engine` (string, optional, one of: `v4`, example `"v4"`) — TTS engine — each billed at its own multiplier. Omit it for the configured default, which GET /v1/engines reports as `isDefault`. The voice must belong to the engine that runs; the two catalogues share no ids.

### Responses

**201** — The re-voiced audio plus the transcript it was built from.

Content-Type: `application/json`

- `success` (boolean, required, example `true`)
- `transcript` (string, required, example `"Xin chào, đây là nội dung được phiên âm."`) — Vietnamese text transcribed from the uploaded audio.
- `voiceUsed` (string, required, example `"Tuyen"`) — Voice the audio was re-synthesized with.
- `audioUrl` (string, required, example `"https://s3.amazonaws.com/bucket/dub/job.wav?X-Amz-Expires=3600&..."`) — Presigned S3 download URL for the dubbed WAV.
- `audioUrlExpiresIn` (number, required, example `3600`) — Seconds until audioUrl expires.
- `duration` (number, required, example `5.2`) — Duration of the dubbed audio (seconds).
- `tokenCost` (number, required, example `80`) — Tokens deducted (computed from the transcript).

**400** — No audio sent, unsupported audio type, or an unknown voice.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**403** — Your plan does not include dubbing or this engine, or the grant is out of tokens / expired.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**413** — Audio file larger than 10 MB.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/emotion-tags

**List emotion styles and inline cue tags**

Operation id: `listEmotionTags`

Returns the reading styles for the `emotion` request field plus the inline cue tags you can embed directly inside the text. Pass `?engine=v4` (or omit it) to get what the engine can actually render: v4 has NO styles and only the three cues it can voice ([cười], [thở dài], [hắng giọng]) — it deletes any other tag from your text, and ignores `emotion` altogether. V3 (two styles, every cue) was retired on 2026-09-24. No authentication required.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `engine` (query, string, optional, one of: `v4`) — Return the styles and cues THIS engine can render. Omit for the default engine.

### Responses

**200** — Styles for the `emotion` field plus the inline cue tags.

Content-Type: `application/json`

- `engine` (string, optional, one of: `v4`, nullable, example `"v4"`) — The engine these styles and cues apply to; null when none was requested.
- `styles` (array of PublicEmotionStyleDto, required) — Reading styles for the `emotion` field. Empty on engines that have none.
  - `id` (string, required, example `"natural"`) — Value for the `emotion` request field.
  - `label` (string, required, example `"Tự nhiên"`) — Human-readable label (Vietnamese).
  - `description` (string, optional, example `"Giọng hội thoại thường ngày (mặc định)."`)
- `tags` (array of PublicEmotionCueDto, required) — Inline cue tags this engine can voice. Any other tag is deleted from your text.
  - `tag` (string, required, example `"[cười]"`) — Inline cue, embedded directly in the text you submit.
  - `label` (string, optional, example `"Cười"`)
  - `description` (string, optional, example `"Tiếng cười ngắn."`)
  - `aliases` (array of string, optional, example `["[laugh]"]`) — Alternative spellings accepted for the same cue.
- `count` (number, required, example `3`) — Number of cues in `tags`.

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/engines

**List engines, what each can do, and what each costs**

Operation id: `listEngines`

The engine registry as it is configured right now: which engines are live, which one you get when you omit `engine`, and the BILLING MULTIPLIER applied to each. Until this endpoint existed the multipliers lived only in prose, so a caller sizing a budget had to trust a table that could go stale the moment pricing changed. `features` tells you what each engine may be asked to do — note that `stream` appears on v4 only, which is why every stream is charged at the v4 rate regardless of what you send. A second factor sits above all of them: `globalMultiplier`, a platform-wide lever. The charge for a request is `characters × engine multiplier × globalMultiplier`, so sizing a budget from the per-engine number alone understates it whenever the global lever is not 1. No authentication required.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Responses

**200** — One entry per enabled engine, in display order.

Content-Type: `application/json`

- `globalMultiplier` (number, optional, example `1`) — Platform-wide multiplier applied on top of the engine multiplier — a traffic-control lever, normally 1. Multiply it in when converting a token allotment to characters, or the answer is short by exactly this factor.
- `engines` (array of object, optional)
  - `key` (string, optional, example `"v4"`)
  - `label` (string, optional, example `"V4 — Nhân bản (48kHz)"`)
  - `isDefault` (boolean, optional) — The engine used when a request omits `engine`.
  - `sampleRate` (integer, optional, example `48000`)
  - `billingMultiplier` (number, optional, example `3.4`) — Multiplies the per-character token charge. A request on an engine with multiplier 3.4 costs 3.4× the base rate, and a little over twice one with multiplier 1.5.
  - `features` (array of string, optional, example `["generate","clone","dialogue","stream","dub","srt"]`) — What this engine may be asked to do. `stream` is the one that decides whether POST /v1/tts/stream accepts it.

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/srt

**Dub an .srt subtitle file into a timecode-aligned WAV**

Operation id: `createSrtDub`

Synthesizes each subtitle cue and places it at its start timecode (silence-padded; overruns shift later cues). Billed by total submitted cue-text characters (~1 token/char, minimum 50, × the AI surcharge only when you opt in with `aiRefine=true`) — NOT output duration. With `aiRefine=true` the subtitles are moderated and each cue is pronunciation-normalized; by default cues are synthesized as provided, with no content check and no surcharge. Pass `engine` to choose the TTS engine (each is billed at its own multiplier), or omit it for the configured default — GET /v1/engines reports it as `isDefault`. Tokens are deducted upfront and refunded if synthesis fails.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Request body (required)

A .srt subtitle file plus the target voice.

Content-Type: `multipart/form-data`

- `file` (string (binary), required)
- `voiceId` (string, required, example `"Ngọc Lan"`)
- `emotion` (string, optional, example `"natural"`)
- `temperature` (number, optional, example `1`)
- `aiRefine` (boolean, optional, default `false`, example `false`) — AI text refinement across all cues (moderation + per-cue pronunciation normalization, billed with the AI surcharge). Defaults to FALSE: cues are synthesized as submitted, with no content check and no surcharge. Pass true to opt in — as a multipart field, the string "true" (or 1/on/yes); anything else, an empty field included, leaves the AI step off.
- `engine` (string, optional, one of: `v4`, example `"v4"`) — TTS engine — each billed at its own multiplier. Omit it for the configured default, which GET /v1/engines reports as `isDefault`. The voice must belong to the engine that runs; the two catalogues share no ids.

### Responses

**201** — The timecode-aligned track, plus per-cue placement showing where speed-fit or drift moved a cue.

Content-Type: `application/json`

- `success` (boolean, required, example `true`)
- `cuesCount` (number, required, example `42`) — Number of subtitle cues synthesized.
- `audioUrl` (string, required, example `"https://s3.amazonaws.com/bucket/srt/job.wav?X-Amz-Expires=86400&..."`) — Presigned S3 download URL for the timecode-aligned WAV.
- `audioUrlExpiresIn` (number, required, example `86400`) — Seconds until audioUrl expires.
- `duration` (number, required, example `128.4`) — Total duration of the dubbed track (seconds).
- `sampleRate` (number, required, example `48000`) — Output sample rate.
- `tokenCost` (number, required, example `1875`) — Tokens deducted (computed from total cue text).
- `placements` (array of PublicSrtPlacementDto, required) — Per-cue timeline placement (speed-fit + drift). May be empty — treat it as diagnostic detail, not as part of the result.
  - `index` (number, required, example `7`) — 1-based cue index.
  - `scheduledStartMs` (number, required, example `12000`) — Start time from the subtitle (ms).
  - `actualStartMs` (number, required, example `12180`) — Start time after timeline assembly (ms).
  - `windowMs` (number, required, example `2000`) — Subtitle window length, end − start (ms).
  - `durationMs` (number, required, example `2400`) — Synthesized cue length after speed-fit (ms).
  - `speed` (number, required, example `1.2`) — Tempo multiplier applied (1.0 = none).
  - `overflowed` (boolean, required, example `true`) — True if the cue was pushed later than its start (drift).

**400** — No file sent, an unparseable .srt, or an unknown voice.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**403** — Your plan does not include SRT dubbing or this engine, or the grant is out of tokens / expired.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**413** — Subtitle file larger than 10 MB.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**422** — Content refused by moderation. Only reachable with `aiRefine=true`.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/tts

**Get the status of several jobs in one call**

Operation id: `getTtsJobs`

Poll up to 50 jobs with ONE request instead of one connection per job. Each entry has exactly the shape of `GET /v1/tts/{jobId}`. Ids that are unknown or not yours are listed in `missing` — a batch is never rejected because of one bad id. Prefer this whenever you have more than one job in flight: it costs the server one database read for the whole batch, and the edge proxy caps concurrent connections per source address.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `ids` (query, string, required, example `"550e8400-e29b-41d4-a716-446655440000,6ba7b810-9dad-11d1-80b4-00c04fd430c8"`) — Comma-separated jobIds, 1–50.

### Responses

**200** — Statuses in request order, plus the ids that could not be resolved.

Content-Type: `application/json`

- `jobs` (array of PublicTtsStatusResponseDto, required) — One entry per id that belongs to you, in the order you asked for them. Each has exactly the shape of GET /v1/tts/{jobId}.
  - `jobId` (string, required, example `"550e8400-e29b-41d4-a716-446655440000"`)
  - `status` (string, required, one of: `queued`, `processing`, `completed`, `failed`, example `"completed"`)
  - `audioUrl` (string, optional, example `"https://s3.amazonaws.com/bucket/tts/job.wav?X-Amz-Expires=86400&..."`) — Presigned S3 download URL. Only present when status=completed. Valid for 24 hours.
  - `audioUrlExpiresIn` (number, optional, example `86400`) — Seconds until audioUrl expires.
  - `duration` (number, optional, example `3.42`)
  - `voiceId` (string, optional, example `"Tuyen"`)
  - `textLength` (number, optional, example `150`)
  - `createdAt` (string (date-time), optional, example `"2026-05-17T10:00:00Z"`)
  - `completedAt` (string (date-time), optional, example `"2026-05-17T10:00:08Z"`)
  - `queuePosition` (number, optional, example `2`) — Position in queue (only when status=queued).
  - `estimatedWaitSeconds` (number, optional, example `8`) — Estimated seconds until processing starts (only when status=queued).
  - `error` (string, optional, example `"Generation failed after retries."`)
- `missing` (array of string, required, example `["0f7c1a2b-0000-4000-8000-000000000000"]`) — Ids that were not found — unknown, malformed, or belonging to another account (indistinguishable on purpose).

**400** — `ids` is missing, empty, or lists more than 50 ids.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/tts

**Submit a TTS generation job**

Operation id: `createTtsJob`

Submits a text-to-speech job. Returns immediately with a `jobId`. Poll `GET /v1/tts/{jobId}` — or `GET /v1/tts?ids=…` for several jobs at once — to check progress and retrieve the audio URL when complete. When identical audio already exists (same text, voice, speed, emotion and engine), the response is `status: "completed"` with `audioUrl` included — nothing to poll. Tokens are deducted on submission, billed by the submitted character count (minimum 50, × the AI surcharge only when you opt in with `aiRefine: true`), and refunded if the job permanently fails. Send an `Idempotency-Key` header whenever you might retry (timeouts, connection resets, a 502 from the edge): the same key with the same body returns the FIRST job instead of creating and charging a second one.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `Idempotency-Key` (header, string, optional) — Optional. A client-chosen key (8–128 printable ASCII characters — a UUID is ideal) that makes this call safe to retry. A second POST with the same key AND the same body returns the FIRST job (same `jobId`, no second charge) with the response header `Idempotent-Replayed: true`. Keys are scoped to your API key and remembered for 24 hours. Same key + different body → 422 `IDEMPOTENCY_KEY_REUSED`; same key while the first call is still running → 409 `IDEMPOTENCY_IN_FLIGHT` with `Retry-After` — retry with the SAME key, never a new one.

### Request body (required)

Content-Type: `application/json`

- `text` (string, required, maxLength 50000, example `"Xin chào Việt Nam!"`) — Vietnamese text to synthesize.
- `voiceId` (string, optional, example `"Trúc Ly"`) — Voice ID — must be one returned by GET /v1/voices for the requested engine. The display name (`name`) is accepted too: "Thiện Minh" renders voice "Anh Khôi". Omit to use that engine's default voice.
- `emotion` (string, optional, one of: `natural`, `storytelling`, `tin_tuc`, `tu_nhien`, `doc_truyen`, `emotion_0`, `emotion_1`, `emotion_2`, `emotion_3`, `emotion_4`, `emotion_5`, `emotion_6`, `emotion_7`, `emotion_8`, `emotion_9`, `emotion_10`, `emotion_11`, `emotion_12`, `emotion_13`, `emotion_14`, `emotion_15`, `emotion_16`, `emotion_17`, `emotion_18`, `emotion_19`, default `"natural"`, example `"natural"`)
- `speed` (number, optional, default `1`, min 0.5, max 2, example `1`) — Playback rate of the synthesized speech. 1.0 is the natural pace.
- `engine` (string, optional, one of: `v4`, example `"v4"`) — TTS engine. Only "v4" (48 kHz, reference-based voices) is served: "v3" was retired on 2026-09-24 and a request naming it is refused with 400. Omit it to use the configured default — GET /v1/engines reports the live answer as `isDefault`, next to each engine’s billing multiplier. The voice must belong to the engine you pick (GET /v1/voices?engine=…); V3 voice ids do not exist on V4.
- `aiRefine` (boolean, optional, default `false`, example `false`) — AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way.

### Responses

**201** — Job accepted: `queued` (poll GET /api/v1/tts/{jobId}) or, when the audio was already in the cache, `completed` with `audioUrl`. When the call replayed an earlier submit under the same `Idempotency-Key`, the body names the original job as `queued` and the response carries `Idempotent-Replayed: true`.

- Header `Idempotency-Key` — Echo of the key you sent, on every keyed response.
- Header `Idempotent-Replayed` — `true` when this response replays an earlier submit under the same key.

Content-Type: `application/json`

- `jobId` (string, required, example `"550e8400-e29b-41d4-a716-446655440000"`)
- `status` (string, required, one of: `queued`, `completed`, example `"queued"`) — `queued` — poll `GET /v1/tts/{jobId}` for the result. `completed` — the audio already existed (same text, voice, speed, emotion and engine as an earlier request, by anyone) and `audioUrl` is in this response: nothing to poll.
- `message` (string, required, example `"Job queued. Poll GET /v1/tts/{jobId} for status and audio URL."`)
- `audioUrl` (string, optional, example `"https://s3.amazonaws.com/bucket/tts/job.wav?X-Amz-Expires=86400&..."`) — Presigned download URL, only when `status` is `completed`. Valid for 24 hours — the same URL the poll would return.
- `audioUrlExpiresIn` (number, optional, example `86400`) — Seconds until audioUrl expires. Only with `status: completed`.
- `duration` (number, optional, example `3.42`) — Audio length in seconds. Only with `status: completed`.

**400** — Unknown voice, voice belonging to another engine, or text too long.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**403** — Your plan does not include this engine or feature, or the grant is out of tokens / expired.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**409** — A request with this `Idempotency-Key` is still in flight (`IDEMPOTENCY_IN_FLIGHT`). Retry after `Retry-After` seconds with the SAME key.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**422** — Content refused by moderation (only reachable with `aiRefine: true`), or `IDEMPOTENCY_KEY_REUSED`: this `Idempotency-Key` was already used with a different body.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/tts/{jobId}

**Get job status and audio URL**

Operation id: `getTtsJob`

Poll this endpoint every 2–3 seconds until `status` is `completed` or `failed`. When `completed`, `audioUrl` contains a presigned S3 URL valid for 24 hours. With several jobs in flight, poll them together with `GET /v1/tts?ids=…` instead.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `jobId` (path, string, required)

### Responses

**200** — Current job state; `audioUrl` is present once `status` is `completed`.

Content-Type: `application/json`

- `jobId` (string, required, example `"550e8400-e29b-41d4-a716-446655440000"`)
- `status` (string, required, one of: `queued`, `processing`, `completed`, `failed`, example `"completed"`)
- `audioUrl` (string, optional, example `"https://s3.amazonaws.com/bucket/tts/job.wav?X-Amz-Expires=86400&..."`) — Presigned S3 download URL. Only present when status=completed. Valid for 24 hours.
- `audioUrlExpiresIn` (number, optional, example `86400`) — Seconds until audioUrl expires.
- `duration` (number, optional, example `3.42`)
- `voiceId` (string, optional, example `"Tuyen"`)
- `textLength` (number, optional, example `150`)
- `createdAt` (string (date-time), optional, example `"2026-05-17T10:00:00Z"`)
- `completedAt` (string (date-time), optional, example `"2026-05-17T10:00:08Z"`)
- `queuePosition` (number, optional, example `2`) — Position in queue (only when status=queued).
- `estimatedWaitSeconds` (number, optional, example `8`) — Estimated seconds until processing starts (only when status=queued).
- `error` (string, optional, example `"Generation failed after retries."`)

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**404** — No job with that id belongs to you. Jobs are scoped to the key’s owner — a valid key proves identity, not ownership of this job.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/tts/stream

**Stream speech synthesis with low latency**

Operation id: `streamSpeech`

Streams audio as it is generated so playback can start within ~1–2s instead of waiting for the whole text. The response is a binary stream (Content-Type: application/octet-stream) of length-prefixed frames: repeat { read a 4-byte big-endian uint32 = N, then read N bytes = one audio frame } until EOF. A COMPLETE stream ends with a zero-length frame (N = 0) — if it is missing, the stream was cut short and you should discard the audio; it is not billed. Each frame is a self-contained WAV by default; pass `outputFormat` for raw headerless `pcm`/`ulaw` to feed a telephony or voice-agent pipeline, or mp3/opus to cut bandwidth. DECODE EACH FRAME SEPARATELY — frames are encoded independently, so concatenating mp3 or opus frames produces gaps and clicks; only pcm/ulaw can simply be joined. The actual sample rate is in X-Sample-Rate, the encoding in X-Output-Format, the framing in X-Stream-Format. PRICE: the same per-character rate as POST /v1/tts, but every stream runs on the v4 engine and is therefore charged at the v4 engine multiplier — higher than the v3 one. Sizing a budget with POST /v1/tts on v3 and then streaming the same text will cost more; GET /v1/engines returns the live multipliers. (+ the AI surcharge only if you opt in with `aiRefine: true`, which also moderates and normalizes the text first.) `voiceId` accepts a preset or one of your own cloned voices (`clone_…`, created in the web Studio at https://vieneu.io/#/clone) — a clone costs exactly what a preset costs on the engine you name; cloning adds no surcharge of its own. Note the engine you name is the engine that renders: a clone is NOT currently checked against the engine it was enrolled on. Clones have enrolled on v4 since 2026-08-28, so for those the two always agree; an older v3-enrolled clone named with v4 will render on v4 from a v3 reference clip and will not sound like the intended voice. Re-enrol it instead.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Request body (required)

Content-Type: `application/json`

- `text` (string, required, maxLength 50000, example `"Xin chào Việt Nam!"`) — Vietnamese text to synthesize and stream.
- `voiceId` (string, optional, example `"Ngọc Lan"`) — Voice ID from GET /v1/voices: a preset, or one of your own cloned voices (`clone_…`, created in the web Studio at https://vieneu.io/#/clone — an admin-published clone works too). A preset may also be given by its display name (`name`). A voice is only usable on the engine it belongs to. Omit to use the default voice.
- `emotion` (string, optional, one of: `natural`, `storytelling`, `tin_tuc`, `tu_nhien`, `doc_truyen`, `emotion_0`, `emotion_1`, `emotion_2`, `emotion_3`, `emotion_4`, `emotion_5`, `emotion_6`, `emotion_7`, `emotion_8`, `emotion_9`, `emotion_10`, `emotion_11`, `emotion_12`, `emotion_13`, `emotion_14`, `emotion_15`, `emotion_16`, `emotion_17`, `emotion_18`, `emotion_19`, default `"natural"`, example `"natural"`)
- `speed` (number, optional, default `1`, min 0.5, max 2, example `1`) — Playback speed (0.5–2.0).
- `engine` (string, optional, one of: `v4`, example `"v4"`) — Streaming runs on v4, the default and only engine. You may omit this. GET /v1/engines returns the live billing multiplier.
- `aiRefine` (boolean, optional, default `false`, example `false`) — AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way.
- `outputFormat` (string, optional, one of: `wav`, `mp3`, `opus`, `pcm`, `ulaw`, default `"wav"`, example `"wav"`) — Encoding of the audio inside each frame. `wav` (default) keeps every frame self-contained and is what existing clients expect. `mp3` and `opus` cut bandwidth roughly six-fold. `pcm` and `ulaw` are HEADERLESS raw audio for telephony and voice-agent pipelines — read the rate from the X-Sample-Rate response header. The frame FRAMING is unchanged in every case, so the end-of-stream marker still means the same thing.
- `sampleRate` (number, optional, one of: `8000`, `16000`, `22050`, `24000`, `44100`, `48000`, example `24000`) — Output sample rate in Hz. Omit for the engine native rate (48000). `opus` is always 48000 and `ulaw` always 8000 — passing a conflicting value is rejected rather than quietly ignored.

### Responses

**200** — A binary stream of length-prefixed frames: repeat { 4-byte big-endian uint32 N, then N bytes }. A COMPLETE stream ends with N = 0; without that marker the audio was cut short — discard it (it is not billed). `X-Sample-Rate`, `X-Output-Format` and `X-Stream-Format` describe the frames.

- Header `X-Sample-Rate` — Sample rate of the audio, in Hz. The ACTUAL rate produced.
- Header `X-Output-Format` — Encoding of each frame: wav, mp3, opus, pcm or ulaw.
- Header `X-Stream-Format` — `len32-wav-chunks` when each frame is a self-contained WAV, `len32-frames` for raw codec bytes.
- Header `X-Stream-Heartbeat` — Present only when this stream may insert HEARTBEAT frames — a valid WAV carrying zero samples, sent at most this many seconds apart to keep the connection alive while nothing new has been synthesized. Skip those frames: they are not audio. Absent means none will be sent (raw codec streams never carry them).

Content-Type: `application/octet-stream` — raw binary body

**400** — Unknown voice, a `clone_…` voice that is not yours (or whose reference is gone), or a format/sample-rate pair that cannot be honoured (`opus` is always 48000, `ulaw` always 8000). Unlike /v1/audio/speech`s `stream_format`, every `outputFormat` is accepted here — the framing is what keeps mp3/opus usable, one frame at a time.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**403** — Your plan does not include this engine or feature, or the grant is out of tokens / expired.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**502** — Every worker for this engine failed. The charge is refunded automatically. Distinct from 503: a 503 means the fleet is alive but out of streaming capacity (see the STREAM_BUSY code) and retrying — or the queued POST /v1/tts path — will work.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**503** — No worker available for the requested engine or format. Retry shortly.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/usage

**Usage of this key (or the whole account) over a window**

Operation id: `getUsage`

Totals by outcome, tokens, characters, seconds of audio and generation time, broken down per day, per route, per voice and per error code — from the per-call ledger every `/v1` call is recorded in. `POST /v1/tts` rows start `queued` and close as `ok`/`error` when the job finishes; `cache_hit` is a request served from the content cache. `tokenCost` is what was deducted before the call ran. Calls refused by the rate limiter never reach the ledger. Window capped at 92 days; default the last 30.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `from` (query, string, optional) — ISO date; default 30 days before `to`.
- `to` (query, string, optional) — ISO date; default now. Window capped at 92 days.
- `scope` (query, string, optional, one of: `key`, `account`) — `key` (default): the calling key only. `account`: every key on the account.

### Responses

**200** — The summary for the window.

Content-Type: `application/json`

- `scope` (string, required, one of: `key`, `account`, example `"key"`)
- `apiKeyId` (string, optional, nullable, example `"6a9c6359b5ea49e3ae862c1c"`) — The key summarised (`scope=key`).
- `userId` (string, optional, nullable, example `"6a9c3db7b5ea49e3ae862a86"`) — The account summarised (`scope=account`).
- `from` (string, required, example `"2026-08-14T00:00:00.000Z"`)
- `to` (string, required, example `"2026-09-13T00:00:00.000Z"`)
- `totals` (UsageTotalsDto, required)
  - `requests` (number, required, example `812`) — Calls that reached a handler in the window (every outcome).
  - `ok` (number, required, example `780`) — Delivered audio (sync routes) or finished jobs (`POST /v1/tts`).
  - `queued` (number, required, example `3`) — `POST /v1/tts` jobs still in the queue at the time of the query.
  - `cacheHit` (number, required, example `12`) — Served from the content cache — billed, no generation.
  - `denied` (number, required, example `15`) — Refused with a 4xx (bad voice, quota, moderation…). Not billed.
  - `error` (number, required, example `2`) — Failed with a 5xx or truncated mid-stream. Refunded.
  - `tokenCost` (number, required, example `61250`) — Tokens deducted (before refunds — see `refunded` on events).
  - `characters` (number, required, example `40810`) — Characters submitted (the billed quantity).
  - `audioSeconds` (number, required, example `3312.4`) — Seconds of audio produced.
  - `avgGenMs` (number, optional, nullable, example `2140`) — Mean worker time per successful call, ms.
  - `p95GenMs` (number, optional, nullable, example `5300`) — 95th percentile of the same, ms.
- `byDay` (array of UsageByDayDto, required)
  - `day` (string, required, example `"2026-09-10"`)
  - `requests` (number, required, example `120`)
  - `ok` (number, required, example `118`)
  - `denied` (number, required, example `1`)
  - `error` (number, required, example `1`)
  - `tokenCost` (number, required, example `9000`)
  - `characters` (number, required, example `6000`)
  - `audioSeconds` (number, required, example `480.2`)
  - `avgGenMs` (number, optional, nullable, example `2100`)
- `byRoute` (array of UsageByRouteDto, required)
  - `route` (string, required, example `"clone"`) — Logical route: `tts`, `tts_stream`, `audio_speech`, `vapi_speech`, `clone`, `dialogue`, `dub`, `srt`, `upload`, `prepare`, `voices_create`, `voices_delete`.
  - `requests` (number, required, example `782`)
  - `ok` (number, required, example `780`)
  - `denied` (number, required, example `1`)
  - `error` (number, required, example `1`)
  - `tokenCost` (number, required, example `58650`)
  - `avgGenMs` (number, optional, nullable, example `2140`)
- `byVoice` (array of UsageByVoiceDto, required) — Top 100 by request count.
  - `voice` (string, required, example `"Binh"`) — Voice id for catalogue/cloned voices; for one-shot cloning, the reference clip handle (basename of the uploaded file).
  - `voiceName` (string, optional, nullable, example `"Thanh Bình"`)
  - `engine` (string, optional, nullable, example `"v4"`)
  - `requests` (number, required, example `300`)
  - `tokenCost` (number, required, example `22500`)
  - `audioSeconds` (number, required, example `1260.5`)
  - `avgGenMs` (number, optional, nullable, example `1980`)
  - `errors` (number, required, example `0`)
- `byErrorCode` (array of UsageByErrorCodeDto, required)
  - `errorCode` (string, required, example `"synthesis_timeout"`) — Same vocabulary as the webhook `error.code`; 4xx refusals appear as `http_<status>`.
  - `statusCode` (number, required, example `500`)
  - `count` (number, required, example `2`)
  - `lastAt` (string, required, example `"2026-09-10T01:02:03.000Z"`)

**400** — Unparseable date, or a scope other than key/account.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/vapi/speech

**Vapi custom-voice webhook (raw PCM for voice agents)**

Operation id: `vapiSpeech`

Point a Vapi assistant's `custom-voice` provider at this URL to give it a Vietnamese voice. Vapi POSTs `{message: {type: "voice-request", text, sampleRate}}` and expects raw mono 16-bit little-endian PCM at exactly that rate — which is what this returns, streamed as it is generated. Authenticate by putting your VieNeu API key in the assistant's `server.secret`; Vapi sends it as `X-VAPI-SECRET`. Choose the voice with `?voiceId=` on the URL (and `?engine=` for v4), since Vapi's payload has no field for it. AI refinement is never applied here: an agent needs the reply to start, not an extra model round-trip. Billed per submitted character like the rest of /v1, and refunded if the audio is cut short.

Authentication: `apiKeyHeader` or `apiKeyBearer` or `vapiSecret`

### Parameters

- `voiceId` (query, string, optional) — Preset voice id or display name (`name` in GET /v1/voices); omit for the engine default.
- `engine` (query, string, optional, one of: `v4`)

### Request body (required)

Content-Type: `application/json`

- `message` (VapiVoiceMessageDto, required)
  - `type` (string, required, example `"voice-request"`)
  - `text` (string, required, example `"Xin chào, tôi có thể giúp gì cho bạn?"`)
  - `sampleRate` (number, required, one of: `8000`, `16000`, `22050`, `24000`, example `24000`)

### Responses

**200** — Raw mono 16-bit little-endian PCM at exactly the `sampleRate` Vapi asked for, streamed as it is generated. Headerless — the rate is the one you sent.

Content-Type: `application/octet-stream` — raw binary body

**400** — Payload is not a `voice-request` message, `text` is empty, or `sampleRate` is not one Vapi and the worker share.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**403** — The grant is out of tokens or expired, or the plan does not allow the requested engine. A daily/weekly cap comes back as 429 instead.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**503** — No worker answered within the deadline. Vapi’s `fallbackPlan` should handle this.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/voices

**List available voices**

Operation id: `listVoices`

Returns the voices an administrator has activated in the catalog (`kind:"catalog"`). Authentication is optional: when you send your API key, the response ALSO includes your own saved cloned voices (`kind:"cloned"`, ids like `clone_…`) — create them in the web Studio at https://vieneu.io/#/clone (cloning is not available through the API), then pass the id as `voiceId` to POST /v1/tts or POST /v1/tts/stream. Each voice carries the `engine` it renders on — a voice is only usable on that engine, cloned voices included (a clone is enrolled for one engine at creation). Pass `?engine=v4` to list one engine only; omitting it lists every engine the server renders on. V3 was retired on 2026-09-24: its voices are no longer listed and `?engine=v3` is a 400.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `engine` (query, string, optional, one of: `v4`) — Return only the voices available on this engine.

### Responses

**200** — The catalogue, plus your own cloned voices when an API key is sent.

Content-Type: `application/json`

- `voices` (array of PublicVoiceDto, required)
  - `id` (string, required, example `"Ngọc Lan"`) — Voice id — pass this as `voiceId` (or `voice` on the OpenAI-compatible route). Cloned voices you created look like `clone_9f1c2d34-…`.
  - `description` (string, optional, example `"Giọng nữ miền Nam, ấm và rõ."`)
  - `name` (string, optional, example `"Ngọc Lan"`) — Display name.
  - `gender` (string, optional, nullable, example `"female"`) — Speaker gender. Explicitly null when the catalogue does not record one.
  - `region` (string, optional, nullable, example `"south"`) — Regional accent. Explicitly null when the catalogue does not record one.
  - `engine` (string, required, one of: `v4`, example `"v4"`) — The engine this voice renders on. A voice is usable on that engine ONLY — passing it with a different `engine` is rejected, not substituted.
  - `kind` (string, required, one of: `catalog`, `cloned`, example `"catalog"`) — `catalog` for a voice VieNeu publishes; `cloned` for one you created in the web Studio (https://vieneu.io/#/clone). Cloned voices are only listed when you send your API key.
- `count` (number, required, example `42`) — Number of voices in `voices`.
- `error` (string, optional, example `"Could not fetch voices"`) — Present only when the catalogue could not be read. The response is still 200 with an empty list — treat a non-empty `error` as "retry", not as "no voices".

**400** — `engine` is not a known engine.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**401** — API key missing, malformed, or revoked.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

**429** — Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/webhooks

**List your webhook endpoints**

Operation id: `listWebhookEndpoints`

The signing secret is never returned here — only `secretPrefix`, which is enough to tell two secrets apart. Rotate the secret if you have lost it.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Responses

**200** — Every webhook endpoint on the account, newest first.

Content-Type: `application/json`

Array of `WebhookEndpointResponseDto`, each:

- `id` (string, required, example `"665f1a2b3c4d5e6f7a8b9c0d"`)
- `url` (string, required, example `"https://api.example.com/hooks/vieneu"`)
- `description` (string, required, example `"Production receiver"`)
- `events` (array of string, required, example `["tts.job.completed","tts.job.failed"]`)
- `status` (string, required, one of: `ENABLED`, `DISABLED`, example `"ENABLED"`)
- `apiKeyId` (string, optional, nullable, example `"665f1a2b3c4d5e6f7a8b9c0d"`) — Null when the endpoint receives events for every key.
- `secretPrefix` (string, required, example `"whsec_a1b2c3"`) — First characters of the signing secret, so you can tell two secrets apart. The secret itself is returned ONCE, at creation and rotation.
- `secretRotatedAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeliveryAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeadLetterAt` (string (date-time), optional, nullable, example `null`) — When an event to this endpoint last gave up permanently.
- `createdAt` (string (date-time), required, example `"2026-09-01T10:00:00Z"`)

---

## POST /api/v1/webhooks

**Register a webhook endpoint**

Operation id: `createWebhookEndpoint`

Registers an HTTPS destination that receives an event when an async job reaches a terminal state, so you can stop polling `GET /v1/tts/{jobId}`.

The response contains the signing **secret exactly once** — store it now, it cannot be retrieved again. Use it to verify the `Vieneu-Signature` header on every event (see the Webhooks guide).

The URL must be `https` and must resolve to a public address. Private, loopback, link-local, CGNAT and reserved ranges are refused here **and** again at the moment of each delivery, so a hostname that is re-pointed at an internal address after registration still fails.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Request body (required)

Content-Type: `application/json`

- `url` (string, required, example `"https://api.example.com/hooks/vieneu"`) — HTTPS destination. Must resolve to a public address — private, loopback, link-local, CGNAT and reserved ranges are refused, both when you register the endpoint and again on every delivery attempt.
- `description` (string, optional, example `"Production receiver"`) — Your own label. Shown back to you; never sent to the receiver.
- `events` (array of string, optional, example `["tts.job.completed","tts.job.failed"]`) — Event types to receive. Defaults to all of them.
- `apiKeyId` (string, optional, example `"665f1a2b3c4d5e6f7a8b9c0d"`) — Restrict this endpoint to jobs submitted with ONE of your API keys. Omit to receive events for every key on the account. Use it to keep a test key's traffic off your production receiver.

### Responses

**201** — Endpoint registered. `secret` is present only in this response.

Content-Type: `application/json`

- `id` (string, required, example `"665f1a2b3c4d5e6f7a8b9c0d"`)
- `url` (string, required, example `"https://api.example.com/hooks/vieneu"`)
- `description` (string, required, example `"Production receiver"`)
- `events` (array of string, required, example `["tts.job.completed","tts.job.failed"]`)
- `status` (string, required, one of: `ENABLED`, `DISABLED`, example `"ENABLED"`)
- `apiKeyId` (string, optional, nullable, example `"665f1a2b3c4d5e6f7a8b9c0d"`) — Null when the endpoint receives events for every key.
- `secretPrefix` (string, required, example `"whsec_a1b2c3"`) — First characters of the signing secret, so you can tell two secrets apart. The secret itself is returned ONCE, at creation and rotation.
- `secretRotatedAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeliveryAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeadLetterAt` (string (date-time), optional, nullable, example `null`) — When an event to this endpoint last gave up permanently.
- `createdAt` (string (date-time), required, example `"2026-09-01T10:00:00Z"`)
- `secret` (string, required, example `"whsec_0000000000000000000000000000000000000000000000000000000000000000"`) — Signing secret. Shown ONCE — store it now, it cannot be retrieved again. Use it to verify the Vieneu-Signature header on every event.

**400** — Destination is not an https URL, points at a non-public address, or the account already has the maximum number of endpoints.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/webhooks/{endpointId}

**Get one webhook endpoint**

Operation id: `getWebhookEndpoint`

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `endpointId` (path, string, required)

### Responses

**200** — The endpoint. Never includes the signing secret.

Content-Type: `application/json`

- `id` (string, required, example `"665f1a2b3c4d5e6f7a8b9c0d"`)
- `url` (string, required, example `"https://api.example.com/hooks/vieneu"`)
- `description` (string, required, example `"Production receiver"`)
- `events` (array of string, required, example `["tts.job.completed","tts.job.failed"]`)
- `status` (string, required, one of: `ENABLED`, `DISABLED`, example `"ENABLED"`)
- `apiKeyId` (string, optional, nullable, example `"665f1a2b3c4d5e6f7a8b9c0d"`) — Null when the endpoint receives events for every key.
- `secretPrefix` (string, required, example `"whsec_a1b2c3"`) — First characters of the signing secret, so you can tell two secrets apart. The secret itself is returned ONCE, at creation and rotation.
- `secretRotatedAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeliveryAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeadLetterAt` (string (date-time), optional, nullable, example `null`) — When an event to this endpoint last gave up permanently.
- `createdAt` (string (date-time), required, example `"2026-09-01T10:00:00Z"`)

**404** — No such endpoint on this account.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## DELETE /api/v1/webhooks/{endpointId}

**Delete a webhook endpoint**

Operation id: `deleteWebhookEndpoint`

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `endpointId` (path, string, required)

### Responses

**200** — Deleted. No further events are sent to this destination.

Content-Type: `application/json`

- `success` (boolean, required, example `true`)

**404** — No such endpoint on this account.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## GET /api/v1/webhooks/{endpointId}/deliveries

**Recent delivery attempts for an endpoint**

Operation id: `listWebhookDeliveries`

Self-diagnosis for a receiver that is not getting events. `status: DEAD` means we gave up and will not send that event again — fetch the result with `GET /v1/tts/{jobId}` instead.

We never record or return your endpoint's response body; `lastError` is a short code and `lastStatusCode` is the HTTP status we saw.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `endpointId` (path, string, required)

### Responses

**200** — The most recent delivery attempts, newest first.

Content-Type: `application/json`

Array of `WebhookDeliveryResponseDto`, each:

- `id` (string, required, example `"665f1a2b3c4d5e6f7a8b9c0d"`)
- `eventId` (string, required, example `"evt_9f2c1e043b8a4d218f770c1a2b3c4d5e"`)
- `eventType` (string, required, example `"tts.job.completed"`)
- `status` (string, required, one of: `PENDING`, `DELIVERED`, `DEAD`, example `"DELIVERED"`) — DEAD means we gave up; the event will not be sent again.
- `attempts` (number, required, example `2`)
- `lastStatusCode` (number, optional, nullable, example `200`)
- `lastError` (string, optional, nullable, example `"http_500"`) — Short failure code. We never record or return your endpoint's response body.
- `lastDurationMs` (number, optional, nullable, example `143`)
- `lastAttemptAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:02Z"`)
- `deliveredAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:02Z"`)
- `createdAt` (string (date-time), required, example `"2026-09-01T10:00:00Z"`)

**404** — No such endpoint on this account.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

## POST /api/v1/webhooks/{endpointId}/rotate-secret

**Rotate the signing secret**

Operation id: `rotateWebhookSecret`

Issues a new signing secret and returns it **once**. The previous secret keeps verifying for 24 hours, and during that window every event carries one `v1=` signature per live secret — so you can deploy the new secret without dropping events signed with the old one.

Authentication: `apiKeyHeader` or `apiKeyBearer`

### Parameters

- `endpointId` (path, string, required)

### Responses

**200** — Rotated. `secret` is the new secret, shown only here.

Content-Type: `application/json`

- `id` (string, required, example `"665f1a2b3c4d5e6f7a8b9c0d"`)
- `url` (string, required, example `"https://api.example.com/hooks/vieneu"`)
- `description` (string, required, example `"Production receiver"`)
- `events` (array of string, required, example `["tts.job.completed","tts.job.failed"]`)
- `status` (string, required, one of: `ENABLED`, `DISABLED`, example `"ENABLED"`)
- `apiKeyId` (string, optional, nullable, example `"665f1a2b3c4d5e6f7a8b9c0d"`) — Null when the endpoint receives events for every key.
- `secretPrefix` (string, required, example `"whsec_a1b2c3"`) — First characters of the signing secret, so you can tell two secrets apart. The secret itself is returned ONCE, at creation and rotation.
- `secretRotatedAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeliveryAt` (string (date-time), optional, nullable, example `"2026-09-01T10:00:00Z"`)
- `lastDeadLetterAt` (string (date-time), optional, nullable, example `null`) — When an event to this endpoint last gave up permanently.
- `createdAt` (string (date-time), required, example `"2026-09-01T10:00:00Z"`)
- `secret` (string, required, example `"whsec_0000000000000000000000000000000000000000000000000000000000000000"`) — Signing secret. Shown ONCE — store it now, it cannot be retrieved again. Use it to verify the Vieneu-Signature header on every event.

**404** — No such endpoint on this account.

Content-Type: `application/json`

Schema: `PublicApiErrorDto` — fields under [Shared schemas](#shared-schemas).

---

