# Cloud API overview

Source: https://docs.vieneu.io/docs/cloud-api/overview

The VieNeu Cloud API turns Vietnamese text into speech over HTTPS. You send text
and a voice id, you get audio back — no model to download, no GPU to rent.

:::note Two different products
This section documents the **hosted API** at `api.vieneu.io`. The **SDK** section
documents the separate on-device package, which runs a model on your own machine.
They share voices and a name, and nothing else: different install, different
billing, different code. If you are integrating VieNeu into a product, you almost
certainly want this section.
:::

If you would rather have audio in front of you before reading any of this, the
**[Quickstart](./quickstart.md)** is a key, a voice and one call.

## The full reference

Every endpoint, every field, every error is in the **[API reference](/api-reference)**,
rendered from the OpenAPI specification the server itself generates. No login
required.

The same document is served as a plain file at
**[`/openapi.json`](pathname:///openapi.json)** — point `openapi-generator`,
`oapi-codegen`, Kiota or your language's equivalent at it and you have a typed
client:

<!-- `pathname://` keeps Docusaurus from treating this as a build asset. Without
     it the markdown loader copies the file to /assets/files/openapi-<hash>.json
     and links THAT — a URL that changes on every spec change, and one that does
     not match the stable path the curl command below tells people to use. -->


```bash
curl -O https://docs.vieneu.io/openapi.json
```

It is generated from the API's own route definitions, and CI fails the build if
the committed file no longer matches them — so the spec cannot quietly fall
behind the *declarations* in the code.

That is a narrower guarantee than it sounds, and worth stating plainly: the check
compares the committed JSON against the decorators, not against what the server
does. A decorator that describes an endpoint incorrectly ships a spec that is
wrong and a check that is green — which is exactly how the cloning endpoint went
on advertising an engine choice for months after the server started refusing one.
Where this documentation and the spec disagree about behaviour, the pages here
are the ones written against the running code.

It covers `/api/v1` only — the application's own dashboard, billing and
administration endpoints are not part of the public contract.

## Base URL

```
https://api.vieneu.io/api/v1
```

## Authentication

Every request carries your API key, either way round:

```bash
-H "Authorization: Bearer vn_sk_..."     # or
-H "X-API-Key: vn_sk_..."
```

Keys beginning `vn_sk_` are live; `vn_test_` keys work the same way but cap each
request at 100 words. Create either on the
**[Developer page](https://www.vieneu.io/#/developer?create=test)** in the VieNeu web app — the
plaintext is shown once, at creation, and never again.

A key is always issued against an **active token grant**, so a new account has to
start one before the page will mint anything; without a grant, creation answers
`403` with `No active API token grant found.` The Developer page offers a free
7-day trial that starts one, once per account.

## The two ways to synthesize

**Synchronous** — you get the audio bytes in the response:

```bash
curl https://api.vieneu.io/api/v1/audio/speech \
  -H "Authorization: Bearer $VIENEU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "input": "Xin chào, đây là VieNeu.", "voice": "Ngọc Lan" }' \
  --output speech.mp3
```

This is the [OpenAI-compatible endpoint](./openai-compatible.md) — the fastest way
in if you already have an OpenAI client, and the one to use for short text.

**Asynchronous** — for long text, submit a job and poll it:

```bash
curl -X POST https://api.vieneu.io/api/v1/tts \
  -H "Authorization: Bearer $VIENEU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "text": "…", "voiceId": "Ngọc Lan" }'
# → { "jobId": "…", "status": "queued" }
#   or, when that exact audio already exists (a cache hit):
#   { "jobId": "…", "status": "completed", "audioUrl": "https://…" } — nothing to poll

curl https://api.vieneu.io/api/v1/tts/<jobId> \
  -H "Authorization: Bearer $VIENEU_API_KEY"
# → { "status": "completed", "audioUrl": "https://…" }
```

With several jobs in flight, poll them together — one request, up to 50 ids,
each entry in the same shape as the single poll:

```bash
curl "https://api.vieneu.io/api/v1/tts?ids=<jobId1>,<jobId2>,<jobId3>" \
  -H "Authorization: Bearer $VIENEU_API_KEY"
# → { "jobs": [ { "jobId": "…", "status": "completed", "audioUrl": "…" }, … ],
#     "missing": [] }
```

Ids that are unknown or not yours land in `missing`; the batch is never refused
because of one bad id. Prefer the batch form whenever you have more than one job
queued: the edge proxy caps concurrent connections per source address, and a
batch costs the server one database read where each single poll costs two.

And when you need audio to start playing before the whole text is generated, use
[streaming](./streaming.md).

### Retrying safely

`POST /v1/tts` creates the job and charges the tokens **before** it answers. If
your request times out, or the connection is reset while the answer is on its
way — a `502` from the edge during a deploy looks exactly like this — you cannot
tell "never happened" from "happened, answer lost", and a blind retry can pay
twice.

Send an `Idempotency-Key` header and it cannot. Choose the key yourself (8–128
printable ASCII characters, no whitespace; a UUID per job is the intended shape)
and reuse it on every retry of *that* request:

```bash
curl -X POST https://api.vieneu.io/api/v1/tts \
  -H "Authorization: Bearer $VIENEU_API_KEY" \
  -H "Idempotency-Key: 6f1c2e0a-7b3d-4c58-9e21-0a5d3b7f9c44" \
  -H "Content-Type: application/json" \
  -d '{ "text": "…", "voiceId": "Ngọc Lan" }'
```

- Same key, same body, first call finished → the **first** job comes back
  (same `jobId`, nothing charged again) with `Idempotent-Replayed: true`.
- Same key, same body, first call still running → `409 IDEMPOTENCY_IN_FLIGHT`
  with `Retry-After: 1`. Retry with the **same** key.
- Same key, different body → `422 IDEMPOTENCY_KEY_REUSED`. Keys are per request.
- A first call that failed with a 4xx (quota, unknown voice) releases its key,
  so a corrected retry under the same key goes through.

Keys are scoped to your API key and remembered for 24 hours. Every keyed
response echoes the key back in `Idempotency-Key`. Without the header the route
behaves exactly as it always has.

## Every operation

Thirty-two operations on twenty-seven paths — the whole public surface. What each one
does, and nothing about its fields: the request bodies live in the
[API reference](/api-reference) alone, because a second description of the API is
a second thing to keep true, and the one that goes stale is never the generated
one.

The reference deep-links by operation id, so `operationId` below is also how you
jump straight to an entry — `/api-reference#operation/createTtsJob` — and how you
name it to a generated client.

**Synthesis**

| Operation | `operationId` | What it does |
|---|---|---|
| `POST /v1/audio/speech` | `createSpeech` | OpenAI-compatible synthesis, sync or streaming |
| `POST /v1/tts` | `createTtsJob` | Submit an asynchronous job |
| `GET /v1/tts/{jobId}` | `getTtsJob` | Poll a job for status and a download URL |
| `GET /v1/tts?ids=…` | `getTtsJobs` | Poll up to 50 jobs in one call |
| `POST /v1/tts/stream` | `streamSpeech` | Low-latency framed streaming |
| `POST /v1/dialogue` | `createDialogue` | Multi-speaker dialogue in one call |
| `POST /v1/dub` | `createDub` | Re-voice an existing recording |
| `POST /v1/srt` | `createSrtDub` | Dub a subtitle file into a timecode-aligned track |
| `POST /v1/vapi/speech` | `vapiSpeech` | [Vapi](./vapi.md) custom-voice webhook — raw PCM for voice agents |

**Voices and metadata**

| Operation | `operationId` | What it does |
|---|---|---|
| `GET /v1/voices` | `listVoices` | The catalogue, plus your own cloned voices when authenticated. No key required |
| `GET /v1/audio/voices` | `listOpenAiVoices` | Ids only, for OpenAI-compatible clients. **Does** need a key |
| `GET /v1/engines` | `listEngines` | Live engines, features and billing multipliers. No key required |
| `GET /v1/emotion-tags` | `listEmotionTags` | Reading styles and inline cue tags, per engine. No key required |

**Usage**

| Operation | `operationId` | What it does |
|---|---|---|
| `GET /v1/balance` | `getBalance` | Tokens this key can spend right now (one plan pays a call: the key's own, or another active plan when it cannot), that plan and its daily / weekly caps. Not billed |
| `GET /v1/usage` | `getUsage` | What this key (or your whole account) did over a window: calls, tokens, seconds of audio, per day / route / voice, errors |

**Cloning — web only**

Cloned voices are created in the web Studio at
[vieneu.io/#/clone](https://vieneu.io/#/clone), not through this API. Enrolment
is a guided job: the Studio denoises the clip, transcribes it, and lets you hear
the result before the voice is saved. A bad reference degrades every later
generation with that voice, not just one request, which is why the API no longer
offers the shortcut. `POST /v1/voices`, `DELETE /v1/voices/{voiceId}`,
`POST /v1/clone`, `POST /v1/upload` and `POST /v1/prepare` answer `410` with
`code: "CLONE_WEB_ONLY"` and bill nothing.

Using a clone through the API is unchanged: it appears in `GET /v1/voices` when
you send your key (`"kind": "cloned"`), and its `clone_…` id is an ordinary
`voiceId` on `POST /v1/tts` and `POST /v1/tts/stream`, at the same price as a
catalogue voice. See [Cloned voices](./streaming.md#cloned-voices).

**Webhooks** — see [Webhooks](./webhooks.md) for the event contract and a
signature verifier you can paste in.

| Operation | `operationId` | What it does |
|---|---|---|
| `POST /v1/webhooks` | `createWebhookEndpoint` | Register an endpoint. Returns the signing secret once |
| `GET /v1/webhooks` | `listWebhookEndpoints` | List your endpoints |
| `GET /v1/webhooks/{endpointId}` | `getWebhookEndpoint` | Read one endpoint |
| `DELETE /v1/webhooks/{endpointId}` | `deleteWebhookEndpoint` | Delete an endpoint |
| `GET /v1/webhooks/{endpointId}/deliveries` | `listWebhookDeliveries` | Recent delivery attempts, with the failure reason |
| `POST /v1/webhooks/{endpointId}/rotate-secret` | `rotateWebhookSecret` | Rotate the signing secret, old one live for 24 hours |

**Audiobooks** — a narrator and up to 30 character voices reading chapters of
script. Books are made in the background, one chapter at a time, while the GPU
fleet is idle: expect hours, not seconds. Books waiting together take turns, a
chapter each. Each chapter is billed per character
when it starts, refunded if it fails, mastered to MP3 and saved to the Library.
Chapters are not reported as `tts.job.*` webhook events — follow the book. The
same feature from a chat: [Audiobooks](../integrations/mcp/audiobooks.md).

| Operation | `operationId` | What it does |
|---|---|---|
| `POST /v1/audiobooks` | `createAudiobook` | Create a draft: title, narrator, cast. LIVE keys only |
| `GET /v1/audiobooks` | `listAudiobooks` | Your books, newest first |
| `GET /v1/audiobooks/{bookId}` | `getAudiobook` | Progress, and a download link (24 h) per finished chapter |
| `POST /v1/audiobooks/{bookId}/chapters` | `addAudiobookChapter` | Add a chapter as a script, or continue one not started (`appendTo`) |
| `POST /v1/audiobooks/{bookId}/start` | `startAudiobook` | Queue it; `402` when your plans cannot pay — in total, or for some chapter, since one plan pays each chapter in full. Also resumes and redoes failed chapters |
| `POST /v1/audiobooks/{bookId}/cancel` | `cancelAudiobook` | Stop. Chapters not started are never billed |

### Voice ids differ by engine

A voice belongs to one engine, and since **2026-09-24** the cloud API has
exactly one: `v4`, whose ids are Vietnamese display names — `Ngọc Lan`, not a
slug. The retired `v3` catalogue used opaque `vieneu-…` slugs, and the two
catalogues shared almost no ids, so a voice copied from an old `v3` listing does
not resolve anywhere any more. It is rejected with `400` rather than silently
mapped to a `v4` voice — and that is now the most common cause of `400`s on the
synthesis routes for callers who hard-coded a voice before the retirement,
shipped, and never had a reason to look at the id again.

**Resolve ids at runtime, scoped to the engine you are about to use:**

```bash
curl -s "https://api.vieneu.io/api/v1/voices?engine=v4"
```

No key needed. `GET /v1/voices` with no filter lists the same `v4` catalogue
(plus your own `v4` clones when you send a key); `?engine=v3` answers `400`
with the retirement message quoted under [Engines](#engines).

## Engines

Requests carry an optional `engine`. Since **2026-09-24 08:00 (GMT+7)** the
only value the cloud API accepts is `v4`, which is also the default, so the
field can simply be omitted. `v3` was retired that morning: any request naming
it — on `POST /v1/tts`, `/v1/tts/stream`, `/v1/audio/speech`, `/v1/dialogue`,
`/v1/dub`, `/v1/srt`, or as `GET /v1/voices?engine=v3` — is refused with `400`
before anything is billed, and the message says why:

```
Engine "v3" was retired on 2026-09-24. Use engine "v4" and a voice from GET /v1/voices?engine=v4 — the two catalogues share no ids.
```

If you had pinned `v3`, the fix has three parts: send `"engine": "v4"` (or
nothing), take a voice id from `GET /v1/voices?engine=v4` rather than reusing
the old one, and re-enrol any clone you made on `v3` in the
[Studio](https://vieneu.io/#/clone). Audio you already generated on `v3` is
untouched — library entries and download links keep working.

The engine is billed at its own multiplier, applied on top of the per-character
rate:

| Engine | Multiplier |
|---|---|
| `v4` | 3× |

That figure is set per deployment and can change, so the table above is a
snapshot, not a contract. **`GET /v1/engines` returns the live values** — no API
key needed. If you are budgeting a large workload, read them from there rather
than from this page:

```bash
curl -s https://api.vieneu.io/api/v1/engines
```

```json
{ "globalMultiplier": 1.3,
  "engines": [
  { "key": "v4", "isDefault": true, "sampleRate": 48000, "billingMultiplier": 3,
    "features": ["generate", "clone", "dialogue", "stream", "dub", "srt"] }
] }
```

### The second multiplier

`globalMultiplier` in that response is a platform-wide lever sitting **on top of**
the per-engine rate. The full charge for one request is:

```
tokens = characters × engine multiplier × globalMultiplier
```

It spends long stretches at `1`, which is exactly why it is easy to miss — and
why it is returned alongside the engine rate rather than documented somewhere
else. At the time of writing it is `1.3`. Budget from the per-engine number alone
and your estimate is short by precisely this factor on any deployment where it
has been moved.

(A third factor, the AI text-refinement surcharge, applies only to requests that
ask for that step; it is not part of the base rate.)

`features` is also how you check, in code, what an engine can do — `stream` is
on `v4`'s list, so every stream runs there and is billed at the `v4` rate, the
same rate as every other call now. A voice belongs to exactly one engine —
`GET /v1/voices?engine=v4` lists that engine's catalogue. Passing a voice from
the retired `v3` catalogue is rejected rather than silently substituted.

**Streaming is `v4` only**, and was before the retirement, for a reason worth
keeping in mind if you ever compare providers: `v3` never really streamed — it
delivered its chunks in a burst at the end — so `POST /v1/tts/stream` refused it
rather than making a time-to-first-audio promise it could not keep. Now that
`v3` is gone from every route the gate is moot: a stream request naming `v3`
gets the same retirement `400` as any other request, and so does
`POST /v1/audio/speech` with `"model": "vieneu-v3"`, which until 2026-09-24
accepted the request, answered 200, and delivered the burst at the end with
nothing in the response saying so. If you are pinning an engine for a streaming
client, pin `v4` — see
[Drop-in for OpenAI-compatible apps](../integrations/openai-clients.md).

A cloned voice streams too, provided it is enrolled on `v4` — every clone
created since 2026-08-28 is, and an older `v3`-enrolled clone can no longer be
rendered at all until it is re-enrolled. See
[Cloned voices](./streaming.md#cloned-voices).

## Billing

Synthesis is billed per **submitted character**, so the cost of a call is
predictable from the request itself, before you make it. Minimum 50 characters
per request. A request that fails to produce audio is refunded automatically.

`aiRefine` defaults to **off** on the API. With it off your text is synthesized
exactly as sent. Turn it on (`"aiRefine": true`) for the AI pass the web app uses
— formulas, acronyms and mixed-in English read correctly, and the content is
checked — billed with a surcharge and one extra round-trip of latency.

Deterministic text preparation runs either way, so Vietnamese is pronounced
correctly regardless.

### The one endpoint that is not billed per character

`POST /v1/dub` is priced by the **output audio duration** — 65 tokens per second,
with a 150-token floor — and charged **only on success**. Dubbing runs speech
recognition before it runs synthesis, so the work tracks how long the recording
is, not how many characters the speaker happened to fit into it. The engine
multiplier still applies on top.

That makes dub the one call whose cost you cannot compute from the request body
before sending it. An upload is gated for affordability against its own duration
first, so a request you cannot pay for is refused before any GPU time is spent on
it.

### Seeing what you were billed for

`GET /v1/balance` is the quick question — *can I afford the next call?*
`remaining` is what one call can spend right now. A call is paid by one plan —
the key's own plan when it can, otherwise another active plan on the account —
so this is the most room any single plan has: its tokens, or less when a daily
or weekly cap is lower. `plan` is the plan that number is measured on, with the
totals, each cap's remainder and reset time (`null` when there is no such cap)
and the expiry. `spendableTokens` is the running total: what every plan that may
pay can still spend, each up to its own caps, added up — it drops by each
call's cost whichever plan paid, and equals `remaining` when one plan pays
everything. `totalRemainingTokens` adds up every active plan on the account. A synthesis
needs at least `50 × engine multiplier` tokens.

`GET /v1/usage` answers "am I being counted correctly" without a support ticket.
It reads the same per-call ledger the operators see: for the calling key
(default) or every key on the account (`scope=account`), over a window of up to
92 days (`from`, `to`; default the last 30 days), it returns totals — calls by
outcome, tokens, characters, seconds of audio, average and p95 generation time —
and breakdowns per day, per route, per voice and per error code.

A few things to know when reading it. `POST /v1/tts` rows start as `queued` and
become `ok` or `error` when the job finishes; a request served from the content
cache is `cache_hit` (billed, no generation time). `tokenCost` is what was
deducted **before** the call ran; `refunded: true` on a row means it was given
back. A call refused by the rate limiter never reaches the ledger — count those
from your own 429s. `requestCount` on your key's own record is something else:
how many times the key *authenticated*, polling included.

## Rate limits and request ids

There are **two** limiters in front of you, and they count different things. An
integration that only knows about one of them will misread half its 429s.

**The application limiter counts your API key.** This is the one that matches
what you bought: it keys on the key itself, so spreading calls across machines
does not buy you more and sharing an office network does not cost you any. Limits
are per route, and every route on `/v1` sets its own:

| Routes | Per minute |
|---|---|
| `/v1/audio/speech`, `/v1/tts`, `/v1/tts/stream`, `/v1/vapi/speech` | 300 |
| `/v1/dialogue` | 20 |
| `/v1/dub`, `/v1/srt` | 10 |
| Webhook management | 10–30, per operation |
| `GET /v1/voices`, `/v1/audio/voices`, `/v1/engines`, `/v1/emotion-tags`, `GET /v1/tts/{jobId}`, `GET /v1/tts?ids=` | not throttled |

When this limiter refuses you, it says so:

```
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 43
Retry-After: 43
X-Request-Id: 0f7c…
```

`X-RateLimit-Reset` is when the window rolls; `Retry-After` is when you may call
again. They usually agree, and when they do not, honour `Retry-After`.

**The edge proxy counts your source address.** In front of the application sits
nginx, which can only key on an IP. On `/api/v1` it allows **3000 requests per
minute and 300 concurrent connections per address**. That is deliberately loose —
it is DDoS padding, not a product limit — but it is real, it is **shared with
every other caller behind your address**, and it knows nothing about your key.
One customer calling from a dozen machines looks like a dozen customers there,
and a dozen customers behind one integration platform's egress IP look like one.

**The tell is the headers.** A `429` carrying `X-RateLimit-*` came from the
application and is about your key. A bare `429` or `503` with none of those
headers and no `Retry-After` came from the edge and is about your IP — nginx
answers with its own page and none of our headers. That difference decides what
to do: back off per key in the first case; in the second, back off *and* look at
how many machines share that egress address, because widening concurrency will
make it worse.

Streaming is the case where this matters most, because `/v1/tts/stream` matches a
different nginx location than the rest of `/api/v1` and inherits a much tighter
connection cap — see [Limits](./streaming.md#limits).

Every response, success or failure, carries `X-Request-Id`. Quote it in a support
request — it is the id our logs are keyed by.

## Errors

Native endpoints return the usual shape:

```json
{ "statusCode": 403, "message": "Insufficient tokens", "traceId": "0f7c…" }
```

`/v1/audio/speech` returns OpenAI's shape instead, so OpenAI clients can parse it.

| Status | Meaning |
|---|---|
| 400 | Malformed request — an unknown voice, a bad format, text the validator refused |
| 401 | Missing, malformed or revoked API key |
| 403 | Out of tokens, grant expired, or your plan does not include this engine |
| 422 | Content refused by moderation (only when `aiRefine` is on) |
| 429 | Rate limit or token quota — see above for telling them apart |
| 503 | No worker available for the requested engine or format; retry shortly |

Which refusals carry a machine-readable `code`, which do not, and what to do
about each is on the [Errors](./errors.md) page.
