# Changelog

Source: https://docs.vieneu.io/vi/docs/cloud-api/changelog

Thay đổi của Cloud API (`/api/v1`). Mọi mục đánh dấu **⚠️ Thay đổi hành vi** đều
có thể ảnh hưởng đến tích hợp đang chạy của bạn, kể cả khi bạn không sửa gì.

---

## 2026-10-10 — Gói của key không trả được thì các gói khác của bạn trả {#2026-10-10--key-billing-fallback}

### ⚠️ Thay đổi hành vi {#️-behaviour-change-2026-10-10}

**Request mà gói của key không trả được thì một gói khác của bạn trả.** Mỗi key
tính tiền trên một gói. Trước đây, khi gói đó không trả được — còn quá ít token,
chạm hạn mức ngày hoặc tuần, hết hạn — request bị từ chối, kể cả khi tài khoản còn
một gói khác đang hoạt động và dư dả: gói còn 99 token không bao giờ trả nổi 195
token của một request V4, nên nó không bao giờ "hết", và mọi lời gọi đều bị từ chối
ngay cạnh một gói quà 400.000 token. Giờ gói của key vẫn trả trước mỗi khi trả được;
khi không, gói cũ nhất trong số các gói còn hoạt động khác của bạn có hỗ trợ engine
đó sẽ trả, đúng như trong web app. Mỗi request vẫn do một gói trả, và tiền hoàn luôn
về đúng gói đã trả. Không có gì rơi xuống phần miễn phí hằng ngày. Sách nói và MCP
server tính tiền theo cùng cách này.

**`GET /v1/balance` theo cùng quy tắc.** `remaining` là chỗ trống nhiều nhất của
một gói trả được (không bao giờ là tổng), và `plan` là gói mà con số đó đo trên —
không còn luôn là gói của key.

**Key có gói không còn hoạt động giờ nhận được code.** Khi gói mà key gắn vào đã
dùng hết, hết hạn hoặc đã được thay, và không gói nào khác đang hoạt động trả được,
lỗi 403 giờ kèm `code: "GRANT_INACTIVE"` và `required`. Trước đây chỉ có câu chữ,
không có code. Xem [Lỗi](./errors.md#quota-and-grant).

**Bắt đầu sách nói tính mọi gói trả được, theo từng chương.**
`POST /v1/audiobooks/{bookId}/start` trước đây chỉ so cuốn sách với gói của key.
Mỗi chương do một gói trả trọn, nên giờ nó trả `402 insufficient_tokens` khi các
gói của bạn cộng lại không đủ cho các chương, hoặc khi có chương tới lượt mà không
gói nào trả được — ví dụ hai chương 39.000 token trên ba gói mỗi gói 30.000. Lỗi
thứ hai nêu tên chương đó, kèm ước tính của chương là `required` và số nhiều nhất
một gói còn lại lúc đó là `remaining`.

### Mới {#new-2026-10-10}

**`spendableTokens` trong `GET /v1/balance`.** Tổng số key tiêu được ngay lúc này,
request này nối request kia: mọi gói trả được, mỗi gói tính tới hạn mức ngày và
tuần của riêng nó, cộng lại. Con số giảm đúng bằng chi phí mỗi request, dù gói nào
trả — `remaining` thì không còn như vậy khi gói của key trả cạnh một gói lớn hơn.
Chỉ một gói thì nó bằng `remaining`. MCP server hiện con số này sau mỗi lần tạo
audio.

**`plan.isGift` trong `GET /v1/balance`.** `plan` là gói còn nhiều chỗ nhất, nên
nó có thể là token VieNeu tặng chứ không phải một gói: gói còn 99 token cạnh
400.000 token được tặng thì `plan` là phần được tặng. `isGift: true` cho biết điều
đó — token tặng không có hạn mức ngày/tuần và không hết hạn — để client không gọi
nó là gói bạn đang dùng. MCP server giờ gọi đúng là token được tặng.

---

## 2026-10-02 — Sách nói dài: không mất chữ, không kẹt {#2026-10-02--audiobooks-resilience}

### ⚠️ Thay đổi hành vi {#️-behaviour-change-2026-10-02}

**Chương dài hơn mức gói tạo được bị từ chối ngay lúc thêm.** Mỗi chương được
tính tiền trong một lần, nên với gói có hạn mức token theo ngày hoặc tuần, chương
tốn nhiều hơn hạn mức không bao giờ bắt đầu được: trước đây nó nằm chờ token mãi.
Giờ `POST /v1/audiobooks/{bookId}/chapters` trả 400 `chapter_over_plan_limit` kèm
`chapterCharsMax`, và mọi cuốn sách đều báo `chapterCharsMax`. Gói không có hạn mức
như vậy thì giữ nguyên (60.000 ký tự).

### Mới {#new-2026-10-02}

**`afterSegment` khi nối tiếp một chương.** Đi cùng `appendTo`, truyền số đoạn
chương đang có; số khác đi thì nhận 409 `segment_mismatch` kèm câu chương đang kết
thúc, nên request gửi lại không thêm chữ hai lần và request bị mất không để lại lỗ
hổng. Không truyền thì request lặp đúng các đoạn cuối của chương nhận 409
`duplicate_segments`, còn chương mới giống hệt chương vừa thêm nhận 409
`duplicate_chapter`. Công cụ MCP `add_audiobook_chapter` nhận `after_segment` và
cho thấy mỗi chương hiện kết thúc thế nào. Xem
[Sách nói](../integrations/mcp/audiobooks.md#limits).

### Thay đổi {#changed-2026-10-02}

**Chương lỗi tự được tạo lại.** Chương có job lỗi — deploy thay máy chủ hai lần
trong lúc tạo một chương dài, máy chủ giọng gặp sự cố — vẫn được hoàn token như
trước, rồi tự tạo lại sau vài phút, tối đa hai lần, dùng tiếp các phần đã xong. Job
đứng yên 45 phút cũng bị đánh lỗi và hoàn token như vậy, nên không còn chặn mọi
sách khác. Bước hoàn thiện MP3 thử lại với thời gian chờ tăng dần trong khoảng một
giờ rồi mới giao WAV, và bản thu tải về bị thiếu, hay MP3 ngắn hơn bản thu, bị từ
chối chứ không được giao.

**Đỉnh của MP3 nằm dưới −3 dBTP.** Bước hoàn thiện giờ đặt mục tiêu thấp hơn nửa
decibel và thêm bộ giới hạn đỉnh trước khi nén MP3, vì bước nén làm đỉnh nhích lên
một chút: hai chương của một cuốn sách thử trên prod đã chạm −2,8 dBTP. Độ lớn vẫn
giữ ở −19 LUFS.

---

## 2026-10-01 — Sách nói, qua API và trong MCP server {#2026-10-01--audiobooks}

### Mới

**Sách nói.** Một cuốn sách gồm một giọng dẫn chuyện và tối đa 30 giọng nhân vật
đọc các chương viết dưới dạng kịch bản — lời dẫn chuyện, và từng câu thoại kèm
nhân vật nói câu đó. `POST /v1/audiobooks` tạo sách,
`POST /v1/audiobooks/{bookId}/chapters` thêm chương, `POST /v1/audiobooks/{bookId}/start`
đưa vào hàng chờ, `GET /v1/audiobooks/{bookId}` theo dõi tiến độ kèm link tải của
từng chương đã xong. Sách được tạo ở chế độ nền, mỗi lần một chương, vào lúc cụm
GPU rảnh, nên mất cỡ vài giờ chứ không phải vài giây. Mỗi chương bị trừ token theo
ký tự khi bắt đầu tạo, được hoàn nếu lỗi, được xử lý âm thanh thành MP3 (−19 LUFS,
đỉnh ≤ −3 dBTP, khoảng lặng hai đầu, thẻ ID3) và lưu vào Thư viện. Chỉ key LIVE và
giọng trong kho. Xem [Toàn bộ operation](./overview.md#every-operation).

**Sách nói trong MCP server.** Sáu công cụ và một trình phát chương giúp Claude
hoặc ChatGPT biến văn bản người dùng đưa thành sách nói nhiều giọng. Xem
[Sách nói](../integrations/mcp/audiobooks.md).

**"VieNeu làm được gì?" trong MCP server.** Công cụ `list_capabilities` (trong
Claude và ChatGPT là một thẻ menu: bấm câu ví dụ để gửi) và năm prompt có sẵn.
Tham số tuỳ chọn của các công cụ giờ nhận cả `null`. Xem
[MCP server](../integrations/mcp/index.md#prompts).

**Đã kết nối trước bản này? Hãy kết nối lại.** App giữ danh sách công cụ lấy
từ lúc kết nối, nên công cụ mới chỉ hiện sau khi bạn ngắt kết nối VieNeu rồi
kết nối lại. Từ nay `serverInfo.version` của server đổi mỗi khi công cụ đổi
(`1.2.0+<dấu vân tay>`), cho các app làm mới theo nó. Xem
[Xử lý sự cố](../integrations/mcp/troubleshooting.md).

**Tìm giọng hiểu lời mô tả vai.** Trong MCP server, `search` của `list_voices`
nhận đúng kiểu trợ lý hay viết, "giọng nam trầm ấm miền Bắc": giới tính và vùng
miền lấy từ thông tin của giọng, từ có dấu phải khớp đúng dấu, các từ như
"giọng" được bỏ qua, và khi không giọng nào có đủ mọi từ thì trả về các giọng
gần nhất kèm phần còn thiếu. Xem [MCP server](../integrations/mcp/index.md#list_voices).

---

## 2026-10-01 — MCP server trả MP3 và có trình phát trong khung chat; `X-Vieneu-Job-Id` trên `/v1/audio/speech` {#2026-10-01--mcp-mp3-and-inline-player}

### Mới

**MCP server trả MP3 và phát ngay trong khung chat.** Văn bản tới 1.500 ký tự
giờ trả về MP3 (nhẹ khoảng 10 lần so với WAV), và trong Claude, ChatGPT kết quả
hiện thành trình phát ngay trong cuộc trò chuyện. Xem
[MCP server](../integrations/mcp/index.md#the-inline-player).

**`POST /v1/audio/speech` cho biết mã của bản lưu.** Response thành công (không
stream) có thêm header `X-Vieneu-Job-Id`: mã mà VieNeu dùng để lưu một bản của
audio bạn vừa nhận. Giây lát sau, `GET /v1/tts/{jobId}` trả `completed` kèm
`audioUrl` đã ký sẵn cho đúng những byte đó — tiện khi bạn cần link để chia sẻ
thay vì file thô. Header này được mở cho trình duyệt đọc qua CORS.

**`GET /v1/balance`.** Số token key đang gọi tiêu được ngay lúc này, gói mà nó
trừ, và hạn mức ngày/tuần của gói kèm giờ làm mới. Không trừ token. MCP server
có cùng thông tin qua công cụ `get_token_balance`, và báo số token còn lại sau
mỗi lần tạo audio xong.

**Đăng nhập MCP dùng được với ChatGPT.** Máy chủ xác thực giờ nhận metadata
document ưu tiên `private_key_jwt` miễn là nó cũng cho phép `none` (ChatGPT là
vậy), và đăng ký động nhận `client_secret_post` / `client_secret_basic`.

---

## 2026-09-30 — MCP server chạy sẵn: dùng VieNeu ngay trong Claude và ChatGPT {#2026-09-30--hosted-mcp-server}

### Mới

**`https://api.vieneu.io/mcp`** — thêm địa chỉ này vào Claude, ChatGPT, Cursor
hoặc Claude Code, đăng nhập tài khoản VieNeu, rồi nhờ đọc tiếng Việt bằng lời
thường. Trợ lý tự tìm giọng, tạo audio và trả link tải. Không cần cài gói, không
cần dán API key: trợ lý đăng nhập qua OAuth 2.1 và bạn cho phép trên một trang
của VieNeu. Mỗi lần gọi công cụ tính phí đúng như lời gọi `/v1` tương ứng. Xem
[MCP server](../integrations/mcp/index.md) và hướng dẫn cho từng app:
[Claude](../integrations/mcp/claude-ai.md), [ChatGPT](../integrations/mcp/chatgpt.md)
và các app khác.

Danh sách app đã kết nối, và nút gỡ kết nối, nằm ở trang Nhà phát triển.

---

## 2026-09-25 — Trúng cache thì `POST /v1/tts` trả `completed` ngay {#2026-09-25--a-cache-hit-on-post-v1tts-answers-completed}

### Mới

**`POST /v1/tts` trả luôn link audio khi audio đã có sẵn.** Yêu cầu giống hệt
nhau — cùng text, giọng, tốc độ, cảm xúc và engine, từ bất kỳ tài khoản nào —
được phục vụ từ cache nội dung thay vì tổng hợp lại. Trước đây lời gọi như vậy
vẫn trả `status: "queued"` như mọi lời gọi khác, đẩy bạn đi poll một đoạn audio
đã có sẵn: mất trọn một chu kỳ poll cho không.

Nay nó trả `status: "completed"` kèm `audioUrl`, `audioUrlExpiresIn` và
`duration` — đúng body mà `GET /v1/tts/{jobId}` sẽ trả — nên không còn gì để
poll. Cách tính phí không đổi (trúng cache vẫn trừ token như trước).

Nếu client của bạn luôn poll sau khi gửi thì không hỏng gì: lần poll trả về cùng
body `completed`. Muốn tận dụng đường tắt, rẽ nhánh theo `status` ở response gửi:

```js
const job = await submit(text, voiceId);
const done = job.status === 'completed' ? job : await pollUntilDone(job.jobId);
```

Lời gọi *không* trúng cache vẫn trả `queued` — replay theo `Idempotency-Key` của
một lần gửi trước cũng vậy, bất kể job đó đang ở trạng thái nào; hãy poll nó.
Thi thoảng file của bản cache vẫn đang trên đường lên kho lưu trữ; khi đó
response giữ `queued` và lần poll sau đó một chút sẽ báo `completed`.

---

## 2026-09-24 — `v3` rút khỏi Cloud API {#2026-09-24--v3-retired-from-the-cloud-api}

### ⚠️ Thay đổi hành vi

**Engine `v3` không còn trên `/api/v1`.** Từ 08:00 ngày 2026-09-24 (GMT+7),
Cloud API chỉ dựng tiếng bằng `v4`. Bất kỳ request nào nêu `"engine": "v3"` —
trên `POST /v1/tts`, `/v1/tts/stream`, `/v1/audio/speech`, `/v1/dialogue`,
`/v1/dub`, `/v1/srt` và các biến thể multipart của chúng — đều bị từ chối với
`400` trước khi bất cứ khoản nào bị tính, và thông báo nói rõ:

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

Request không nêu engine thì vốn đã rơi vào `v4`, engine mặc định, nên không bị
ảnh hưởng — trừ khi nó mang theo một voice id của `v3`, là điểm kế tiếp.

**Các endpoint danh mục giờ trả gì.** `GET /v1/engines` liệt kê đúng một engine,
`{ "key": "v4", "isDefault": true, "sampleRate": 48000, "billingMultiplier": 3,
"features": ["generate", "clone", "dialogue", "stream", "dub", "srt"] }`, kèm
`"globalMultiplier": 1.3` bên cạnh. `GET /v1/voices` chỉ liệt kê giọng `v4`,
cộng thêm các bản nhân bản `v4` của chính bạn khi gửi kèm key; giọng `v3`, và
bản nhân bản đăng ký trên `v3`, không còn được liệt kê. `GET /v1/voices?engine=v3`
là `400` với thông báo ở trên. Id của danh mục đã ngừng — phần lớn là slug
`vieneu-…` khó đọc — gần như không chung không gian với tên hiển thị của `v4`,
nên một giọng `v3` hard-code sẵn vẫn tiếp tục lỗi ở `voiceId` kể cả khi bạn đã
bỏ `engine`: hãy lấy một id mới từ `GET /v1/voices?engine=v4`.

**Trên route tương thích OpenAI**, `"model": "vieneu-v3"` trả `400` kèm
`Model 'vieneu-v3' is retired. Engine "v3" was retired on 2026-09-24. …`.
`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`, `vieneu` và `vieneu-v4` đều trỏ về `v4`.
Trường `emotion` (`natural` / `storytelling`) vốn chỉ dành cho `v3`; nó vẫn được
nhận, rồi bị bỏ qua — `v4` không có kiểu đọc.

**`GET /v1/emotion-tags` không kèm `engine`** giờ trả lời cho engine mặc định,
`v4`: không có kiểu đọc và ba thẻ biểu cảm đặt trong văn bản, `[cười]`,
`[thở dài]` và `[hắng giọng]`. Trước đây nó trả bộ của `v3`. `?engine=v3` là
`400`. Các endpoint `/v1/audio/…` tương ứng cũng vậy.

**Bản nhân bản đăng ký trên `v3`** — mọi giọng tạo trước 2026-08-28, thời điểm
việc đăng ký chuyển sang `v4` — không còn dựng tiếng được qua API, vì engine duy
nhất đọc được đoạn mẫu của chúng chính là engine giờ trả `400`. Gửi một id như
vậy làm `voiceId` (hay `voice` của `/v1/audio/speech`) sẽ nhận `400` kèm
`code: "CLONE_ENGINE_RETIRED"` và `engine` của giọng, thay vì bị âm thầm dựng
trên `v4` bằng một chất giọng bạn chưa từng đăng ký. Hãy đăng ký lại
giọng trên [Studio](https://vieneu.io/#/clone); id `clone_…` mới chạy trên
`POST /v1/tts` và `POST /v1/tts/stream` y như trước.

**Những gì không bị ảnh hưởng.** Audio đã tạo trên `v3` vẫn nguyên — các mục
trong thư viện và link tải vẫn hoạt động. Và đây chỉ là thay đổi của Cloud API:
bản **v3 Turbo** chạy tại chỗ trong Python SDK và ứng dụng Windows chạy trên máy
của bạn, không đổi gì.

---

## 2026-09-23 — Nhân bản giọng chuyển hẳn về Studio web {#2026-09-23--cloning-moved-to-the-web-studio}

### ⚠️ Thay đổi hành vi

**Các endpoint tạo giọng nhân bản đã đóng.** `POST /v1/voices`,
`DELETE /v1/voices/{voiceId}`, `POST /v1/clone`, `POST /v1/upload` và
`POST /v1/prepare` nay trả `410` kèm `code: "CLONE_WEB_ONLY"` và `cloneUrl`.
Không trừ token, không chạm tới GPU — lỗi từ chối xảy ra trước cả hai việc đó.
Hãy tạo giọng tại [vieneu.io/#/clone](https://vieneu.io/#/clone).

Vì sao đưa về web: nhân bản cho ra giọng tốt là việc cần có người dẫn. Studio
khử nhiễu clip, chép lời bằng Whisper và cho bạn nghe thử trước khi lưu. Một clip
mẫu ồn, chép lời sai hoặc sai độ dài sẽ làm hỏng **mọi** lần tạo tiếng sau này
bằng giọng đó chứ không riêng lần tạo giọng — mà gọi qua API thì không cách nào
thấy được điều đó trước khi đã trả phí tạo giọng.

**Phần tạo tiếng không đổi gì.** Giọng nhân bản trên web vẫn hiện trong
`GET /v1/voices` khi bạn gửi kèm key (`"kind": "cloned"`), và id `clone_…` vẫn là
một `voiceId` bình thường ở `POST /v1/tts` và `POST /v1/tts/stream`, giá đúng
bằng giọng catalogue. Hệ tích hợp nào chỉ DÙNG giọng nhân bản thì không phải sửa
gì cả.

Các route nhân bản đã được gỡ khỏi [`/openapi.json`](pathname:///openapi.json) và
[tài liệu tham chiếu API](/api-reference), nên client sinh tự động sẽ mất chúng ở
lần sinh lại kế tiếp. Các code lỗi nhân bản cũ (`CLONE_MONTHLY_CAP`,
`CLONE_QUOTA_EXCEEDED`, `CLONE_REF_*`, …) thuộc về các route đó nên không còn tới
được một API key nữa; những trần theo gói ấy vẫn áp nguyên trên Studio. Xem
[Lỗi → Nhân bản giọng](./errors.md#cloning).

---

## 2026-09 — Body lỗi, code hạn mức, và cổng engine khi tạo giọng nhân bản {#2026-09--error-bodies-quota-codes-and-the-clone-engine-gate}

### ⚠️ Thay đổi hành vi — chỉ thêm vào, không bỏ đi gì {#️-behaviour-change--additive-nothing-removed}

Đọc dòng này trước đã: **không có trường nào bị bỏ, bị đổi tên, hay đổi kiểu dữ
liệu lẫn ý nghĩa.** Cả ba thay đổi dưới đây chỉ thêm nội dung vào những body vốn
đã được gửi. Thứ duy nhất có thể nhận ra khác biệt là một tích hợp từ chối trường
lạ — một schema chặt, hay một struct Go giải mã có bật kiểm tra trường không khai
báo — và đó chính là lý do mục này được xếp vào thay đổi hành vi thay vì phần Mới.

**Mọi body lỗi dạng gốc trên `/v1` giờ đều mang `statusCode`.** Trước đây trường
này chỉ có ở những body không mang gì khác: ngay khi một lỗi từ chối có thêm `code`
máy đọc được thì nó mất `statusCode` — tức là đổi lấy một lý do máy đọc được thì
mất đi một trường bạn vẫn luôn đọc được. Giờ chỉ còn một dạng duy nhất, trên mọi
lỗi mà envelope của nền tảng bao phủ. Dòng status của HTTP vẫn là bản chuẩn.
`POST /v1/audio/speech` không bị ảnh hưởng — route đó trả lời bằng envelope của
OpenAI, vốn không có `statusCode`.

**Lỗi hạn mức giờ mang `code`, kèm `resetAt`, `remaining` và `required` ở những chỗ
bước trừ token cung cấp được.** Tám chỗ từ chối trên bảy route có tính tiền —
`/v1/tts`, `/v1/tts/stream`, `/v1/dialogue`, `/v1/dub` (hai chỗ: cổng kiểm khả
năng chi trả trước khi tốn GPU, và bước trừ tiền sau đó), `/v1/clone`, `/v1/srt`
và `/v1/vapi/speech` — trước đây chỉ ném ra một câu trần trụi, nên cách duy nhất để
phân biệt "nạp thêm" với "chờ một tiếng" là dò câu chữ tiếng Anh. Giờ chúng mang
`GRANT_EXPIRED`, `GRANT_TOKENS_EXHAUSTED`, `GRANT_DAILY_LIMIT`,
`GRANT_WEEKLY_LIMIT` hoặc `CONCURRENT_CONFLICT`, và hai loại trần tự phục hồi thì
kèm `resetAt` theo chuẩn ISO 8601 nói rõ khi nào hết. `message` giữ nguyên, nên một
client chỉ đọc mỗi trường đó vẫn chạy như cũ. Xem
[Lỗi](./errors.md#quota-and-grant) để biết từng code nghĩa là gì và phải làm gì.

**`POST /v1/voices` giờ kiểm đúng engine sẽ thực sự chạy.** Tạo giọng nhân bản chỉ
còn chạy trên `v4` từ 2026-08-28, nhưng chốt kiểm ở cửa vào vẫn đang khẳng định
những điều về `v3` — nên một lượt tạo giọng không ghi engine nào vẫn có thể bị từ
chối vì tình trạng của một engine nó không bao giờ dùng tới. Giờ phép kiểm chạy
trên đúng engine mà request sẽ dùng để tạo giọng, và chỉ chạy khi bạn ghi rõ engine.

- *Bạn sẽ thấy:* một lượt tạo giọng bỏ trống `engine` — tức đường đi bình thường
  — không còn hỏng vì những lý do chẳng liên quan gì tới nó.
- *Không có gì đổi:* ghi rõ `"engine": "v3"` thì vẫn bị từ chối với
  `400 CLONE_ENGINE_NOT_ALLOWED`, và ghi rõ một engine mà gói của bạn không có thì
  vẫn là `403`.

---

## 2026-09 — Webhooks {#2026-09--webhooks}

### Mới {#new}

**Webhooks cho tác vụ bất đồng bộ.** Đăng ký một endpoint `https` bằng
`POST /v1/webhooks`, và thay vì polling `GET /v1/tts/{jobId}`, bạn nhận
`tts.job.completed` hoặc `tts.job.failed` ngay khi job kết thúc. Sự kiện mang đủ
thông tin để xử lý mà không cần gọi thêm, kèm một URL tải audio có hiệu lực một
giờ.

- Ký bằng HMAC-SHA256 trong header `Vieneu-Signature: t=…,v1=…` — cùng khuôn
  với Stripe, nên bộ kiểm chữ ký bạn đã viết dùng lại được gần như nguyên vẹn.
  Đoạn `verifySignature` chép-dán-là-chạy có trong [Webhooks](./webhooks.md).
- **Gửi ít nhất một lần và không theo thứ tự.** Trùng lặp là chuyện bình thường:
  hãy khử trùng lặp theo `id` (ổn định theo từng job + loại sự kiện). Đừng khử
  trùng lặp theo chữ ký — mỗi lần thử đều được ký lại nên `t` và `v1` đều đổi.
- Một lần gửi hỏng sẽ được thử lại, tổng cộng tối đa 6 lượt, ở các mốc `t+0`, `+0.5m`,
  `+2.0m`, `+5.5m`, `+13.0m`, `+28.5m` — tức khoảng **28,5 phút** cho cả loạt,
  và khoảng cách dài nhất giữa hai lần thử là **15,5 phút**. (Mục này ban đầu
  ghi "khoảng 15 phút"; đó là tính nhầm công thức backoff, xem
  [Webhooks](./webhooks.md#retries-and-backoff).) Hãy đặt mọi ngưỡng đối soát
  **trên 15,5 phút**. Hết lượt thử thì sự kiện bị **đưa vào dead-letter**: không
  gửi lại nữa, tài khoản của bạn nhận một thông báo, và
  `GET /v1/webhooks/{id}/deliveries` cho biết vì sao. Audio **không** mất —
  `GET /v1/tts/{jobId}` vẫn trả về job và một URL tải mới.
- Endpoint phải là `https` và phải trỏ tới địa chỉ công khai. Dải IP riêng,
  loopback, link-local, CGNAT và các dải dành riêng đều bị từ chối — cả lúc đăng
  ký lẫn tại đúng thời điểm gửi. Mọi phản hồi `3xx` bị tính là gửi hỏng.
- Xoay signing secret bằng `POST /v1/webhooks/{id}/rotate-secret`. Secret cũ còn
  hiệu lực 24 giờ và mỗi sự kiện mang một `v1=` cho mỗi secret còn hiệu lực, nên
  bạn triển khai secret mới mà không rớt sự kiện nào.

Không có gì thay đổi hành vi: polling vẫn chạy y hệt, và tài khoản chưa đăng ký
endpoint nào thì không thấy khác biệt gì.

---

## 2026-08 — Định dạng đầu ra, tương thích OpenAI, và Vapi {#2026-08--output-formats-openai-compatibility-and-vapi}

### Mới {#new-1}

**Giọng nhân bản chạy được trên `POST /v1/tts/stream`.** Truyền id `clone_…`
(lấy từ `GET /v1/voices`, tạo bằng `POST /v1/voices`) vào `voiceId` là stream
được, y như một giọng có sẵn. Trước đây đường stream từ chối mọi id `clone_…`
trong khi ứng dụng web đã stream giọng nhân bản từ lâu — worker vốn hỗ trợ,
chỉ có bề mặt công khai là chặn.

- Tính tiền **không đổi**: vẫn theo số ký tự gửi lên × hệ số của engine. Giọng
  nhân bản không có phụ phí riêng; khoản duy nhất là phí ghi danh một lần ở
  `POST /v1/voices`.
- Giọng nhân bản gắn với đúng engine lúc ghi danh, nên quy tắc "stream chỉ chạy
  `v4`" vẫn áp dụng. Clone mới đều ghi danh `v4`.
- Không phải là thay đổi hành vi: những gì chạy được hôm qua vẫn chạy y hệt.

**Nhiều định dạng đầu ra.** `POST /v1/tts/stream` và `POST /v1/audio/speech` giờ
nhận `mp3`, `opus`, `pcm` (thô, không header) và `ulaw` (G.711 8 kHz cho tổng
đài), kèm chọn sample rate. Trước đây chỉ có WAV 48 kHz. mp3 nhỏ hơn WAV khoảng
6 lần.

**`POST /v1/audio/speech` là bản drop-in thật sự cho OpenAI.** Trỏ SDK OpenAI vào
`https://api.vieneu.io/api/v1` với API key của bạn là chạy, không sửa gì thêm.
`speed` đã được áp dụng, `model` chọn engine, và `stream_format` cho phép nhận
audio ngay trong lúc đang tạo (chunked hoặc SSE kiểu OpenAI). Thêm
`GET /v1/audio/voices` để các client OpenAI lấy danh sách giọng.

**`POST /v1/vapi/speech`** — webhook custom-voice cho [Vapi](https://vapi.ai).
Trỏ `custom-voice` của assistant vào URL này là agent thoại của bạn nói tiếng
Việt. Chi tiết trong [Tích hợp Vapi](./vapi.md).

**Header hạn mức trên mọi phản hồi.** `X-RateLimit-Limit`,
`X-RateLimit-Remaining`, `X-RateLimit-Reset`, và `X-Request-Id` — hãy đưa kèm
`X-Request-Id` khi liên hệ hỗ trợ, đó là id dùng để tra log của chúng tôi.

**Tài liệu công khai.** Mục Cloud API trên docs.vieneu.io, kèm decoder tham chiếu
Python và JavaScript cho streaming.

### ⚠️ Thay đổi hành vi {#️-behaviour-change}

**`aiRefine` mặc định chuyển từ BẬT sang TẮT.**
Bước tinh chỉnh bằng AI (kiểm duyệt nội dung + chuẩn hoá phát âm cho công thức,
từ viết tắt, tiếng Anh xen kẽ) trước đây chạy mặc định trên `/v1` và tính thêm
phụ phí. Giờ bạn phải chủ động bật bằng `"aiRefine": true`.

- *Bạn sẽ thấy gì:* chi phí mỗi request **giảm**, độ trễ **giảm**, và văn bản
  được đọc **đúng như bạn gửi**.
- *Nếu bạn muốn giữ hành vi cũ:* thêm `"aiRefine": true` vào request. Với
  `POST /v1/srt` (multipart) là trường `aiRefine=true`.
- Chuẩn hoá văn bản tất định (cách đọc hoá học, gộp CHỮ HOA, tầng phiên âm
  sea-g2p) vẫn chạy trong mọi trường hợp — tiếng Việt vẫn đọc đúng.

**Mặc định của `response_format` trên `/v1/audio/speech` chuyển từ `wav` sang
`mp3`.** Đây là mặc định của OpenAI, và một client OpenAI bỏ trống trường này là
đang mong đợi mp3. Nếu bạn cần WAV, gửi `"response_format": "wav"`.

**Hạn mức đổi đơn vị: từ mỗi IP sang mỗi API key.** Trước đây đếm theo địa chỉ
IP, nghĩa là một văn phòng dùng chung mạng phải chia nhau hạn mức, còn một key
chạy trên nhiều máy thì được nhân lên. Giờ đếm theo key — đúng thứ bạn mua. Đi
kèm, trần của ba route tổng hợp chính đã được **nâng lên 300 request/phút** để
không ai bị hụt so với trước.

Đây là hạn mức của **tầng ứng dụng**. Đứng trước nó là một proxy biên vẫn đếm
theo địa chỉ nguồn, và tầng đó không đổi. Cách phân biệt: `429` có kèm
`X-RateLimit-*` là key của bạn chạm trần ứng dụng; `429`/`503` trần trụi không
mang header nào là proxy biên chặn theo IP — dùng chung với mọi người đứng sau
địa chỉ đó. Xem [Hạn mức](./overview.md#rate-limits-and-request-ids).

### Sửa lỗi {#fixed}

- Streaming qua `api.vieneu.io` không còn bị proxy đệm lại — audio đến ngay khi
  được tạo, đúng như thiết kế.
- `POST /v1/tts/stream` giờ báo đúng định dạng và sample rate thật trong header
  `X-Output-Format` / `X-Sample-Rate`, thay vì lặp lại thứ bạn yêu cầu.
- Stream bị cắt giữa chừng được hoàn tiền đáng tin cậy hơn: một stream kết thúc
  đúng quy cách nhưng **không mang audio nào** giờ cũng được hoàn.

### Giới hạn đã biết {#known-limitations}

`stream_format` (và streaming nói chung) chỉ nên dùng với `pcm` và `ulaw`. Các
khung audio được mã hoá độc lập nhau, nên nối mp3 lại sẽ có khoảng lặng ở mỗi
mối nối, còn opus trở thành một chuỗi stream Ogg mà phần lớn trình duyệt chỉ
phát được stream đầu tiên. Cần một file mp3 hoặc opus hoàn chỉnh thì gửi request
thường, không streaming.
