Changelog
Changes to the Cloud API (/api/v1). Anything marked ⚠️ Behaviour change can
affect a running integration of yours even if you change nothing.
2026-10-10 — A key whose plan cannot pay uses your other plans
⚠️ Behaviour change
A request your key's plan cannot pay for is paid by another of your plans. A key is billed to one plan. When that plan could not pay — too few tokens left, a daily or weekly cap reached, expired — the request was refused, even with another active plan on the account holding plenty: a plan with 99 tokens left could never pay the 195 a V4 request costs, so it never ran out, and every call was refused beside a 400,000-token gift. Now the key's plan still pays first whenever it can; when it cannot, the oldest of your other active plans that covers the engine pays, exactly as in the web app. One request is still paid by one plan, and a refund always goes back to the plan that paid. Nothing falls through to the free daily allowance. Audiobooks and the MCP server bill the same way.
GET /v1/balance follows the same rule. remaining is the most room any
single plan that can pay has (never a sum), and plan is the plan that number
is measured on — no longer always the key's own.
A key whose plan is no longer active now gets a code. When the plan the key
is bound to is used up, expired or replaced and no other active plan can pay,
the 403 now carries code: "GRANT_INACTIVE" and required. It used to be prose
with no code. See Errors.
Starting an audiobook counts every plan that can pay, chapter by chapter.
POST /v1/audiobooks/{bookId}/start used to weigh the book against the key's
plan alone. Each chapter is paid in full by one plan, so it now answers
402 insufficient_tokens when your 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 — say two chapters of 39,000 tokens on three plans of 30,000 each. That
second refusal names the chapter, with its estimate as required and the most
any one plan would have left as remaining.
New
spendableTokens in GET /v1/balance. What the key can spend in total right
now, request after request: every plan that may pay, each up to its own daily
and weekly caps, added up. It drops by each request's cost, whichever plan paid
— remaining no longer does when the key's own plan pays beside a larger one.
With one plan it equals remaining. The MCP server shows it after each
synthesis.
plan.isGift in GET /v1/balance. Since plan is the plan with the most
room, it can be a token gift from VieNeu rather than a package: a package down
to 99 tokens beside a 400,000-token gift reports the gift. isGift: true says
so — a gift has no daily or weekly cap and never expires — so a client does not
present it as the package you are on. The MCP server now calls it a gift.
2026-10-02 — Long audiobooks: nothing lost, nothing stuck
⚠️ Behaviour change
A chapter longer than the plan can render is refused when added. A chapter
is billed in one go, so on a plan with a daily or weekly token cap, a chapter
costing more than the cap could never start: it waited for tokens forever.
POST /v1/audiobooks/{bookId}/chapters now answers 400 chapter_over_plan_limit
with chapterCharsMax, and every book reports chapterCharsMax. Plans without
such a cap are unchanged (60,000 characters).
New
afterSegment when continuing a chapter. With appendTo, pass how many
segments the chapter has; a different count answers 409 segment_mismatch with
the words the chapter ends with, 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 answers 409 duplicate_segments, and a new chapter identical to
the last one 409 duplicate_chapter. The MCP tool add_audiobook_chapter takes
after_segment and shows how each chapter now ends. See
Audiobooks.
Changed
Failed chapters are made again on their own. A chapter whose job fails — a deploy replacing the server twice during one long chapter, a voice server down — is refunded as before, then rendered again a few minutes later, at most twice, reusing the parts already made. A job that stops moving for 45 minutes is failed and refunded the same way, so it can no longer hold up every other book. Mastering to MP3 retries with growing waits for about an hour before handing over the WAV, and a take cut short in transit, or an MP3 shorter than its take, is refused instead of delivered.
MP3 peaks stay under −3 dBTP. Mastering now aims half a decibel lower and adds a peak limiter before the MP3 encoder, which lifts peaks a little: two chapters of a test book rendered on production had reached −2.8 dBTP. Loudness is unchanged at −19 LUFS.
2026-10-01 — Audiobooks, over the API and in the MCP server
New
Audiobooks. A book is a narrator and up to 30 character voices reading
chapters written as a script — narration, and each line of dialogue with the
character who says it. POST /v1/audiobooks creates one,
POST /v1/audiobooks/{bookId}/chapters adds chapters, POST /v1/audiobooks/{bookId}/start
queues it and GET /v1/audiobooks/{bookId} follows it, with a download link for
every finished chapter. Books are made in the background, one chapter at a time,
while the GPU fleet is idle, so they take hours rather than seconds. Each chapter
is billed per character when it starts, refunded if it fails, mastered to MP3
(−19 LUFS, peaks ≤ −3 dBTP, silence at both ends, ID3 tags) and saved to the
Library. LIVE keys and catalogue voices only. See
Every operation.
Audiobooks in the MCP server. Six tools and a chapter player let Claude or ChatGPT turn a text the user provides into a multi-voice audiobook. See Audiobooks.
"What can VieNeu do?" in the MCP server. A list_capabilities tool (a menu
card in Claude and ChatGPT: click an example to send it) and five ready-made
prompts. Optional tool arguments now also accept null. See
MCP server.
Connected before this release? Reconnect. Apps keep the tool list they
fetched when they connected, so the new tools appear after you disconnect
VieNeu and connect again. From now on the server's serverInfo.version
changes whenever its tools do (1.2.0+<fingerprint>), for apps that refresh on
it. See Troubleshooting.
Voice search that reads a casting brief. In the MCP server, list_voices
takes search the way assistants write it, "giọng nam trầm ấm miền Bắc":
gender and region come from the voice's fields, a word written with diacritics
must match them, words such as "giọng" are ignored, and when no voice has every
word the closest come back with what each lacks. See
MCP server.
2026-10-01 — MP3 and an inline player in the MCP server; X-Vieneu-Job-Id on /v1/audio/speech
New
The MCP server returns MP3 and plays it in the chat. Texts up to 1,500 characters now come back as MP3 (about a tenth of the WAV size) instead of WAV, and in Claude the result shows an audio player right in the conversation. See MCP server.
POST /v1/audio/speech names its saved copy. A successful non-streaming
response now carries X-Vieneu-Job-Id: the id under which VieNeu keeps a copy
of the audio you just received. A moment later GET /v1/tts/{jobId} answers
completed with a presigned audioUrl for the same bytes — useful when you
want a shareable link rather than the raw file. The header is exposed to
browsers through CORS.
GET /v1/balance. How many tokens the calling key can spend right now,
the plan it bills, and that plan's daily and weekly caps with their reset
times. Not billed. The MCP server exposes the same thing as the
get_token_balance tool and adds the remaining balance to every finished
synthesis.
MCP sign-in works with ChatGPT. The authorization server now accepts a
client metadata document that prefers private_key_jwt as long as it also
allows none (ChatGPT's does), and dynamic registration accepts
client_secret_post / client_secret_basic.
2026-09-30 — Hosted MCP server: VieNeu inside Claude and ChatGPT
New
https://api.vieneu.io/mcp — add this URL to Claude, ChatGPT, Cursor or
Claude Code, sign in with your VieNeu account, and ask for Vietnamese speech in
plain language. The assistant searches voices, synthesizes and returns a
download link. No package to install and no API key to paste: the assistant
signs in through OAuth 2.1 and you approve it on a VieNeu page. Tool calls are
billed exactly like the equivalent /v1 calls. See MCP server and the per-app guides for
Claude, ChatGPT and others.
Connected apps are listed, and can be disconnected, on the Developer page.
2026-09-25 — A cache hit on POST /v1/tts answers completed
New
POST /v1/tts returns the audio URL at once when the audio already exists.
Identical requests — the same text, voice, speed, emotion and engine, from any
account — are served from a content cache rather than synthesized again. Such a
call used to answer status: "queued" like every other, sending you off to poll
for audio that was already there: one full poll interval spent on nothing.
It now answers status: "completed" with audioUrl, audioUrlExpiresIn and
duration — exactly the body GET /v1/tts/{jobId} would give — so there is
nothing to poll. Billing is unchanged (a cache hit is charged, as before).
If your client always polls after a submit, nothing breaks: the poll returns the
same completed body. To take the shortcut, branch on status in the submit
response:
const job = await submit(text, voiceId);
const done = job.status === 'completed' ? job : await pollUntilDone(job.jobId);
A submit that is not a cache hit still answers queued — and so does an
Idempotency-Key replay of an earlier submit, whatever that job's state; poll it.
The cached row's bytes are occasionally still on their way to storage, in which
case the response stays queued and the poll reports completed a moment later.
2026-09-24 — v3 retired from the cloud API
⚠️ Behaviour change
The v3 engine is gone from /api/v1. From 2026-09-24 08:00 (GMT+7) the
cloud API renders on v4 only. Any request that names "engine": "v3" — on
POST /v1/tts, /v1/tts/stream, /v1/audio/speech, /v1/dialogue,
/v1/dub, /v1/srt and their multipart variants — is refused with 400
before anything is billed, and the message says so:
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.
A request that names no engine was already landing on v4, the default, and
is not affected — unless it carries a v3 voice id, which is the next point.
What the catalogue endpoints say now. GET /v1/engines lists a single
engine, { "key": "v4", "isDefault": true, "sampleRate": 48000, "billingMultiplier": 3, "features": ["generate", "clone", "dialogue", "stream", "dub", "srt"] }, with "globalMultiplier": 1.3 beside it. GET /v1/voices
lists v4 voices only, plus your own v4 clones when you send a key; v3
voices, and clones enrolled on v3, are no longer listed. GET /v1/voices?engine=v3 is a 400 with the message above. The retired
catalogue's ids — mostly opaque vieneu-… slugs — shared almost no space with
v4's display names, so a hard-coded v3 voice keeps failing on voiceId even
once you drop engine: take a fresh id from GET /v1/voices?engine=v4.
On the OpenAI-compatible route, "model": "vieneu-v3" answers 400 with
Model 'vieneu-v3' is retired. Engine "v3" was retired on 2026-09-24. ….
tts-1, tts-1-hd, gpt-4o-mini-tts, vieneu and vieneu-v4 all resolve to
v4. The emotion field (natural / storytelling) was v3-only; it is
still accepted, and ignored — v4 has no reading styles.
GET /v1/emotion-tags with no engine now answers for the default engine,
v4: no styles and three inline cues, [cười], [thở dài] and
[hắng giọng]. It used to answer with v3's set. ?engine=v3 is a 400. The
/v1/audio/… equivalents behave the same way.
Clones enrolled on v3 — anything created before 2026-08-28, when
enrolment moved to v4 — can no longer be rendered through the API, because
the only engine that could read their reference clip is the one that now
answers 400. Naming one as voiceId (or as the voice of /v1/audio/speech)
answers 400 with code: "CLONE_ENGINE_RETIRED" and the voice's engine,
instead of quietly rendering the clip on v4 in a voice you never enrolled.
Re-enrol the voice in the Studio;
the new clone_… id works on POST /v1/tts and POST /v1/tts/stream exactly
as before.
What is not affected. Audio already generated on v3 is untouched —
library entries and download links keep working. And this is a cloud-API change
only: the on-device v3 Turbo in the Python SDK and the Windows app runs on
your own machine and is unchanged.
2026-09-23 — Cloning moved to the web Studio
⚠️ Behaviour change
The clone-enrolment endpoints are closed. POST /v1/voices,
DELETE /v1/voices/{voiceId}, POST /v1/clone, POST /v1/upload and
POST /v1/prepare now answer 410 with code: "CLONE_WEB_ONLY" and a
cloneUrl. Nothing is billed and no GPU work starts — the refusal happens
before either. Create the voice at
vieneu.io/#/clone instead.
Why the web: enrolling well is a guided job. The Studio denoises the clip, transcribes it with Whisper and lets you hear the voice before it is saved. A reference that is noisy, mistranscribed or the wrong length degrades every later generation with that voice, not just the call that created it — and an API caller had no way to see that before spending the enrolment fee.
Nothing changes for generation. A voice cloned on the web is listed by
GET /v1/voices when you send your key ("kind": "cloned") and its clone_… id
remains an ordinary voiceId on POST /v1/tts and POST /v1/tts/stream, at the
same price as a catalogue voice. Integrations that only use clones need no
change at all.
The clone routes are gone from /openapi.json and
the API reference, so generated clients will drop them on their
next regeneration. The old clone error codes (CLONE_MONTHLY_CAP,
CLONE_QUOTA_EXCEEDED, CLONE_REF_*, …) belonged to those routes and can no
longer reach an API key; the same plan limits still apply in the Studio. See
Errors → Cloning.
2026-09-16 — Safe retries and batch polling on POST /v1/tts
New
Idempotency-Key on POST /v1/tts. The server creates the job and charges
the tokens before it answers, so a client whose request timed out — or was cut
by a 502 from the edge during a deploy — could never tell "never happened" from
"happened, answer lost". Send a client-chosen key (8–128 printable ASCII
characters; a UUID is ideal) and a retry with the same key and the same body
returns the first job: same jobId, no second charge, and the response
header Idempotent-Replayed: true. Keys are scoped to your API key and kept for
24 hours. Same key with a different body is refused with 422 IDEMPOTENCY_KEY_REUSED; the same key while the first call is still running is
409 IDEMPOTENCY_IN_FLIGHT with Retry-After: 1 — retry with the same key,
never a new one. Requests without the header behave exactly as before. See
Retrying safely.
GET /v1/tts?ids=a,b,c — poll up to 50 jobs with one request. Each entry
has exactly the shape of GET /v1/tts/{jobId}; ids that are unknown or not yours
are listed in missing rather than failing the batch. Not throttled, like the
single poll. Prefer it whenever more than one job is in flight: the edge proxy
caps concurrent connections per source address, and one batch call costs the
server one database read where eight single polls cost sixteen.
retryAfterSeconds in error bodies, mirrored as Retry-After.
CONCURRENT_CONFLICT now says how long to wait (one second) instead of leaving
you to guess; QUEUE_FULL and USER_QUEUE_FULL already carried it, and it is
now documented on the error body. USER_QUEUE_FULL — the per-account ceiling on
queued jobs — is also new to this document, though not to the API.
Behaviour change — additive
A full queue no longer touches your balance. POST /v1/tts used to charge,
discover the queue was full, and refund inline. It now asks the queue first, so
a QUEUE_FULL / USER_QUEUE_FULL answer moves no money — which is also what its
message has always promised.
2026-09-13 — GET /v1/usage
New
GET /v1/usage — your own usage, from the same per-call ledger the platform
keeps: calls by outcome, tokens, characters, seconds of audio, generation time
(average, p95), broken down per day, per route, per voice and per error code.
scope=key (default) is the calling key; scope=account merges every key on
the account. Window up to 92 days. See
Seeing what you were billed for.
Nothing else changed for callers. Behind it, every /v1 call is now recorded
with its key, route, voice, cost and outcome — the reason a usage question can
be answered exactly rather than estimated.
2026-09 — Error bodies, quota codes, and the clone engine gate
⚠️ Behaviour change — additive, nothing removed
Read this line first: no field was removed, renamed, or changed in type or meaning. All three changes below only put more into a body that was already being sent. The only integration that can notice is one that rejects unknown fields — a strict schema, a Go struct decoded with unknown-field checking — and that is the reason this is filed as a behaviour change rather than under New.
Every native /v1 error body now carries statusCode. It used to appear
only on the bodies that had nothing else in them: the moment a refusal gained a
machine-readable code, it lost statusCode in the same move, so gaining a
reason cost you a field you had always been able to read. One shape now, on
every error the platform envelope covers. The HTTP status line remains the
authoritative copy. POST /v1/audio/speech is unaffected — it answers in
OpenAI's envelope, which has no statusCode.
Quota refusals now carry code, plus resetAt, remaining and required
where the deduction supplies them. Eight refusal sites across the seven billed
routes — /v1/tts, /v1/tts/stream, /v1/dialogue, /v1/dub (two of them: the
affordability gate before any GPU work, and the charge after it), /v1/clone,
/v1/srt and /v1/vapi/speech — used to throw a bare sentence, so the only way
to tell "top up" from "wait an hour" was to match English prose. They
now carry GRANT_EXPIRED, GRANT_TOKENS_EXHAUSTED, GRANT_DAILY_LIMIT,
GRANT_WEEKLY_LIMIT or CONCURRENT_CONFLICT, and the two limits that lift on
their own carry an ISO 8601 resetAt saying when. message is unchanged, so a
client reading only that keeps working. See Errors
for what each code means and what to do about it.
POST /v1/voices now gates the engine that actually runs. Enrolment has
been v4-only since 2026-08-28, but the entry check was still asserting things
about v3 — so an enrolment that named no engine could be refused because of
the state of an engine it was never going to use. The check now runs against the
engine the request will really enrol on, and only when you named one explicitly.
- What you will see: an enrolment that omits
engine— the normal path — no longer fails for reasons that have nothing to do with it. - What has not changed: an explicit
"engine": "v3"is still refused with400 CLONE_ENGINE_NOT_ALLOWED, and an explicit engine your plan does not cover is still403.
2026-09 — Webhooks
New
Webhooks for asynchronous work. Register an https endpoint with
POST /v1/webhooks, and instead of polling GET /v1/tts/{jobId} you receive
tts.job.completed or tts.job.failed the moment the job ends. The event
carries everything you need to act on it without a second call, plus a download
URL that lives for one hour.
- Signed with HMAC-SHA256 in a
Vieneu-Signature: t=…,v1=…header — the same shape Stripe uses, so the signature verifier you have already written carries over almost untouched. A copy-paste-and-runverifySignatureis in Webhooks. - At-least-once and unordered delivery. Duplicates are normal: dedupe on
id(stable per job and event type). Do not dedupe on the signature — every attempt is signed afresh, sotandv1both change. - A failed delivery is retried up to 6 attempts, at
t+0,+0.5m,+2.0m,+5.5m,+13.0mand+28.5m— about 28.5 minutes for the whole run, and the longest single gap between two attempts is 15.5 minutes. (This entry originally said "about 15 minutes"; that was arithmetic on the wrong backoff formula, see Webhooks.) Size any reconciliation threshold above 15.5 minutes. Once the attempts run out the event is dead-lettered: it will not be sent again, a notification appears in your account, andGET /v1/webhooks/{id}/deliveriestells you why. The audio is not lost —GET /v1/tts/{jobId}still returns the job and a fresh download URL. - The endpoint must be
httpsand must point at a public address. Private ranges, loopback, link-local, CGNAT and reserved ranges are all refused — both at registration and again at the moment of delivery. Any3xxresponse counts as a failed delivery. - Rotate the signing secret with
POST /v1/webhooks/{id}/rotate-secret. The old secret keeps working for 24 hours and each event carries onev1=per live secret, so you can roll out the new one without dropping a single event.
Nothing changes behaviour here: polling works exactly as before, and an account that has registered no endpoint sees no difference at all.
2026-08 — Output formats, OpenAI compatibility, and Vapi
New
Cloned voices work on POST /v1/tts/stream. Pass a clone_… id (from
GET /v1/voices, created with POST /v1/voices) as voiceId and it streams,
just like a built-in voice. The streaming path used to refuse every clone_… id
while the web app had been streaming cloned voices for a long time — the worker
supported it all along; only the public surface blocked it.
- Billing is unchanged: still submitted characters × the engine multiplier.
Clones carry no surcharge of their own; the only charge is the one-off
enrolment fee on
POST /v1/voices. - A cloned voice is bound to the engine it enrolled on, so the "streaming is
v4only" rule still applies. New clones all enrol onv4. - This is not a behaviour change: whatever worked yesterday still works exactly the same.
Multiple output formats. POST /v1/tts/stream and POST /v1/audio/speech
now take mp3, opus, pcm (raw, headerless) and ulaw (G.711 8 kHz for
telephony), with a choice of sample rate. Before, WAV at 48 kHz was the only
option. mp3 is roughly 6× smaller than WAV.
POST /v1/audio/speech is a real drop-in for OpenAI. Point an OpenAI SDK at
https://api.vieneu.io/api/v1 with your API key and it works, with nothing else
to change. speed is now applied, model selects the engine, and
stream_format lets you take audio while it is still being generated (chunked,
or OpenAI-style SSE). GET /v1/audio/voices was added so OpenAI clients can
list voices.
POST /v1/vapi/speech — the custom-voice webhook for Vapi.
Point your assistant's custom-voice at this URL and your voice agent speaks
Vietnamese. Details in Vapi integration.
Rate-limit headers on every response. X-RateLimit-Limit,
X-RateLimit-Remaining, X-RateLimit-Reset, and X-Request-Id — quote
X-Request-Id when you contact support, it is the id our logs are keyed by.
Public documentation. The Cloud API section on docs.vieneu.io, with reference decoders in Python and JavaScript for streaming.
⚠️ Behaviour change
aiRefine now defaults to OFF, not ON.
The AI refinement step (content moderation + pronunciation normalisation for
formulas, acronyms and mixed-in English) used to run by default on /v1 and
carried a surcharge. You now have to ask for it explicitly with
"aiRefine": true.
- What you will see: cost per request down, latency down, and your text read exactly as you sent it.
- If you want the old behaviour: add
"aiRefine": trueto the request. OnPOST /v1/srt(multipart) it is anaiRefine=truefield. - Deterministic text preparation (chemistry readings, UPPERCASE folding, the sea-g2p phoneme layer) runs in every case — Vietnamese is still pronounced correctly.
The response_format default on /v1/audio/speech moves from wav to
mp3. That is OpenAI's default, and an OpenAI client that leaves the field
empty is expecting mp3. If you need WAV, send "response_format": "wav".
Rate limits change unit: from per IP to per API key. They used to count IP addresses, which meant an office sharing one network had to split a limit between them, while one key spread across several machines multiplied it. They now count the key — the thing you actually bought. Alongside that, the ceiling on the three main synthesis routes was raised to 300 requests per minute so that nobody ends up with less than before.
This is the application-tier limit. In front of it sits an edge proxy that
still counts source addresses, and that tier has not changed. How to tell them
apart: a 429 carrying X-RateLimit-* is your key hitting the application
ceiling; a bare 429/503 with no headers at all is the edge proxy blocking by
IP — shared with everyone else behind that address. See
Rate limits.
Fixed
- Streaming through
api.vieneu.iois no longer buffered by the proxy — audio arrives as it is generated, the way it was designed to. POST /v1/tts/streamnow reports the real format and sample rate in theX-Output-Format/X-Sample-Rateheaders, instead of echoing back what you asked for.- Truncated streams are refunded more reliably: a stream that ends correctly but carries no audio at all is now refunded too.
Known limitations
stream_format (and streaming in general) should only be used with pcm and
ulaw. Audio frames are encoded independently of one another, so concatenated
mp3 has a gap of silence at every join, and opus becomes a chain of Ogg streams
that most browsers only play the first of. If you need a complete mp3 or opus
file, send an ordinary, non-streaming request.