Skip to main content

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 with 400 CLONE_ENGINE_NOT_ALLOWED, and an explicit engine your plan does not cover is still 403.

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-run verifySignature is 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, so t and v1 both change.
  • A failed delivery is retried up to 6 attempts, at t+0, +0.5m, +2.0m, +5.5m, +13.0m and +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, and GET /v1/webhooks/{id}/deliveries tells you why. The audio is not lost — GET /v1/tts/{jobId} still returns the job and a fresh download URL.
  • The endpoint must be https and 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. Any 3xx response 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 one v1= 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 v4 only" rule still applies. New clones all enrol on v4.
  • 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": true to the request. On POST /v1/srt (multipart) it is an aiRefine=true field.
  • 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.io is no longer buffered by the proxy — audio arrives as it is generated, the way it was designed to.
  • POST /v1/tts/stream now reports the real format and sample rate in the X-Output-Format / X-Sample-Rate headers, 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.