# Changelog

Source: https://docs.vieneu.io/docs/cloud-api/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 {#2026-10-10--key-billing-fallback}

### ⚠️ 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](./errors.md#quota-and-grant).

**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 {#2026-10-02--audiobooks-resilience}

### ⚠️ 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](../integrations/mcp/audiobooks.md#limits).

### 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 {#2026-10-01--audiobooks}

### 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](./overview.md#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](../integrations/mcp/audiobooks.md).

**"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](../integrations/mcp/index.md#prompts).

**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](../integrations/mcp/troubleshooting.md).

**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](../integrations/mcp/index.md#list_voices).

---

## 2026-10-01 — MP3 and an inline player in the MCP server; `X-Vieneu-Job-Id` on `/v1/audio/speech` {#2026-10-01--mcp-mp3-and-inline-player}

### 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](../integrations/mcp/index.md#the-inline-player).

**`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 {#2026-09-30--hosted-mcp-server}

### 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](../integrations/mcp/index.md) and the per-app guides for
[Claude](../integrations/mcp/claude-ai.md), [ChatGPT](../integrations/mcp/chatgpt.md) 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:

```js
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](https://vieneu.io/#/clone);
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](https://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`](pathname:///openapi.json) and
the [API reference](/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](./errors.md#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](./overview.md#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](./overview.md#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](./errors.md#quota-and-grant)
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](./webhooks.md).
- **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](./webhooks.md#retries-and-backoff).) 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](https://vapi.ai).
Point your assistant's `custom-voice` at this URL and your voice agent speaks
Vietnamese. Details in [Vapi integration](./vapi.md).

**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](./overview.md#rate-limits-and-request-ids).

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