Skip to main content

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.

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 is a key, a voice and one call.

The full reference​

Every endpoint, every field, every error is in the 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 — point openapi-generator, oapi-codegen, Kiota or your language's equivalent at it and you have a typed client:

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:

-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 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:

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 — 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:

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:

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.

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:

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 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

OperationoperationIdWhat it does
POST /v1/audio/speechcreateSpeechOpenAI-compatible synthesis, sync or streaming
POST /v1/ttscreateTtsJobSubmit an asynchronous job
GET /v1/tts/{jobId}getTtsJobPoll a job for status and a download URL
GET /v1/tts?ids=…getTtsJobsPoll up to 50 jobs in one call
POST /v1/tts/streamstreamSpeechLow-latency framed streaming
POST /v1/dialoguecreateDialogueMulti-speaker dialogue in one call
POST /v1/dubcreateDubRe-voice an existing recording
POST /v1/srtcreateSrtDubDub a subtitle file into a timecode-aligned track
POST /v1/vapi/speechvapiSpeechVapi custom-voice webhook — raw PCM for voice agents

Voices and metadata

OperationoperationIdWhat it does
GET /v1/voiceslistVoicesThe catalogue, plus your own cloned voices when authenticated. No key required
GET /v1/audio/voiceslistOpenAiVoicesIds only, for OpenAI-compatible clients. Does need a key
GET /v1/engineslistEnginesLive engines, features and billing multipliers. No key required
GET /v1/emotion-tagslistEmotionTagsReading styles and inline cue tags, per engine. No key required

Usage

OperationoperationIdWhat it does
GET /v1/balancegetBalanceTokens 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/usagegetUsageWhat 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, 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.

Webhooks — see Webhooks for the event contract and a signature verifier you can paste in.

OperationoperationIdWhat it does
POST /v1/webhookscreateWebhookEndpointRegister an endpoint. Returns the signing secret once
GET /v1/webhookslistWebhookEndpointsList your endpoints
GET /v1/webhooks/{endpointId}getWebhookEndpointRead one endpoint
DELETE /v1/webhooks/{endpointId}deleteWebhookEndpointDelete an endpoint
GET /v1/webhooks/{endpointId}/deliverieslistWebhookDeliveriesRecent delivery attempts, with the failure reason
POST /v1/webhooks/{endpointId}/rotate-secretrotateWebhookSecretRotate 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.

OperationoperationIdWhat it does
POST /v1/audiobookscreateAudiobookCreate a draft: title, narrator, cast. LIVE keys only
GET /v1/audiobookslistAudiobooksYour books, newest first
GET /v1/audiobooks/{bookId}getAudiobookProgress, and a download link (24 h) per finished chapter
POST /v1/audiobooks/{bookId}/chaptersaddAudiobookChapterAdd a chapter as a script, or continue one not started (appendTo)
POST /v1/audiobooks/{bookId}/startstartAudiobookQueue 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}/cancelcancelAudiobookStop. 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 400s 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:

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​

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. 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:

EngineMultiplier
v43×

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:

curl -s https://api.vieneu.io/api/v1/engines
{ "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.

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.

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:

RoutesPer minute
/v1/audio/speech, /v1/tts, /v1/tts/stream, /v1/vapi/speech300
/v1/dialogue20
/v1/dub, /v1/srt10
Webhook management10–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.

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:

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

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

StatusMeaning
400Malformed request — an unknown voice, a bad format, text the validator refused
401Missing, malformed or revoked API key
403Out of tokens, grant expired, or your plan does not include this engine
422Content refused by moderation (only when aiRefine is on)
429Rate limit or token quota — see above for telling them apart
503No 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 page.