# Node n8n

Source: https://docs.vieneu.io/vi/docs/integrations/n8n

:::info Trạng thái phát hành: đã dựng xong, chưa công bố
Community node của VieNeu đã hoàn thiện và đã kiểm thử, nhưng **gói này chưa được
phát hành công khai**. Nó được đánh dấu `private: true` trong `package.json` và
chưa bao giờ được đẩy lên npm, nên hôm nay không có gì để bạn cài từ một public
registry.

Nếu bạn muốn chạy nó ngay, **hãy liên hệ VieNeu** và hỏi xin gói — chúng tôi có
thể đưa bạn một bản build. Theo dõi
[changelog Cloud API](../cloud-api/changelog) để biết thông báo khi nó lên npm;
phần [Cài đặt](#install) bên dưới có cả hai đường.

Mọi thứ còn lại trên trang này — các operation, tham số, field đầu ra và hành vi
khi lỗi — đều đúng với gói đã build, nên bạn có thể quyết định node này có phải
thứ mình cần hay không trước khi hỏi xin.
:::

VieNeu có một community node cho n8n — một node, bốn resource, audio ra dưới dạng
một binary field. Đây là một **programmatic node**, không phải trigger: nó nằm
giữa workflow và biến text tiếng Việt thành một file mà node kế tiếp có thể gửi,
lưu hay tải lên.

Có hai cách gọi VieNeu từ n8n:

- **Node** — ô chọn giọng có tìm kiếm, tự động định tuyến giữa endpoint text ngắn
  và text dài, và lỗi API được ánh xạ vào `failure.cause` máy đọc được của n8n.
  Cần gói (xem trên) và một n8n tự host.
- **HTTP Request node thuần** — không phải cài gì, chạy được trên n8n Cloud, và
  ai cũng dùng được ngay hôm nay. Xem
  [Không muốn cài node?](#dont-want-to-install-the-node) ở cuối trang. Hai
  template workflow nhập sẵn đi kèm gói.

:::caution Đừng `npm install n8n-nodes-vieneu`
Cái tên đó **chưa ai giữ chỗ trên npm**, nên thứ lệnh này phân giải ra hôm nay
không phải gói của chúng tôi. Đừng cài nó và đừng dán nó vào hộp thoại
community node của n8n.
:::

## Cài đặt {#install}

Chỉ n8n tự host. n8n Cloud chỉ cài các gói đã được kiểm định trên npm, nên đường
community node ở đó không dùng được dù có chuyện gì xảy ra — hãy dùng
[đường HTTP Request](#dont-want-to-install-the-node) thay thế.

### Khi gói đã được phát hành {#when-the-package-is-published}

Đây là hình dạng của việc cài đặt sau này. **Chưa cái nào chạy được cả** — gói
chưa có trên npm:

```text
Settings → Community nodes → Install → n8n-nodes-vieneu
```

Khi đó sẽ không cần đến bất cứ thứ gì bên dưới. Hãy xem
[changelog](../cloud-api/changelog) trước khi đi theo đường thủ công.

### Hôm nay {#today}

Hỏi xin VieNeu gói này. Thứ bạn nhận là thư mục mã nguồn `n8n-nodes-vieneu/`,
bạn tự build lấy; repository chứa nó là private, nên không có `git clone` nào để
đưa bạn.

Yêu cầu trước:

| Yêu cầu | Từ đâu ra |
| --- | --- |
| **Node 20.19 trở lên** | `engines.node: ">=20.19"` trong `package.json` của gói |
| **pnpm** — `corepack enable` là đủ | gói ghim `packageManager: "pnpm@10.24.0"`; npm và yarn không thay thế được ở đây (xem bên dưới) |
| **n8n đóng gói kèm `n8n-workflow` 2.37.1 trở lên** | phiên bản mà gói được biên dịch theo, và mã nguồn import `NodeConnectionTypes`, thứ các bản cũ hơn không export |

n8n không công bố một con số "phiên bản tối thiểu" rõ ràng cho chuyện này; hãy
kiểm tra instance của bạn thực sự đóng gói kèm cái gì bằng `npm ls n8n-workflow`
ở nơi cài n8n. Gói khai báo `n8nNodesApiVersion: 1`. Trên một bản n8n quá cũ để
export `NodeConnectionTypes`, node không hề xuất hiện trong panel; trên bản quá
cũ so với type `Failure`, việc rẽ nhánh theo `failure.cause` mô tả
[bên dưới](#how-failures-surface) chẳng làm gì cả.

Build nó:

```bash
cd n8n-nodes-vieneu
pnpm install
pnpm build
pnpm verify
```

`pnpm build` là bắt buộc. `dist/` bị git ignore, và `dist/` đúng là thứ mà khối
`n8n` trong `package.json` trỏ n8n vào — một bản chép mới tinh thì không có nó.

`pnpm verify` là phép kiểm đáng chạy. Nó `require()` bản `dist/` đã build theo
đúng cách loader của n8n làm và khởi tạo class được export, bắt được ba lỗi mà
nếu không thì chỉ lộ ra dưới dạng một node lặng lẽ không bao giờ xuất hiện trong
panel: một đường dẫn trong `package.json` không còn khớp bố cục được emit, một
icon mà `tsc` đã không chép sang, và một class n8n không dựng lên được.

Dùng `pnpm`, đừng dùng `npm install`. Gói nằm ngoài workspace của chính
repository và mang theo một `pnpm-workspace.yaml` rỗng để biến thư mục của nó
thành workspace root; nếu pnpm không đọc file đó, một lần install chạy từ đây sẽ
đi ngược lên tận gốc repository và không cài gì cả, thoát ra mã 0 mà chẳng có gì
để build.

Rồi thì hoặc chép bản build vào, hoặc trỏ n8n tới thư mục gói:

```bash
# Option A — copy into n8n's private-node folder, then restart n8n.
mkdir -p ~/.n8n/custom
cp -r dist/nodes dist/credentials ~/.n8n/custom/

# Option B — leave it in place and point n8n at it.
export N8N_CUSTOM_EXTENSIONS="/abs/path/to/n8n-nodes-vieneu"
n8n start
```

Hai lỗi tốn cả một buổi chiều:

- `~/.n8n/custom` **không phải** `~/.n8n/nodes`. Cái sau chứa các community node
  cài từ npm; một bản build riêng đặt vào đó sẽ bị bỏ qua.
- `N8N_CUSTOM_EXTENSIONS` nhận một danh sách đường dẫn tuyệt đối ngăn cách bằng
  **dấu chấm phẩy**, trên mọi nền tảng. Danh sách ngăn bằng dấu hai chấm không
  báo lỗi — nó chỉ nạp về con số không.

Với Docker, mount gói đã build vào container rồi đặt chính biến đó trỏ tới đường
dẫn bên trong container.

Node khi đó sẽ xuất hiện với tên **VieNeu** trong panel node. Type đầy đủ của nó
là `n8n-nodes-vieneu.vieneu`.

:::caution Đường cài này chưa được thử trên một n8n đang chạy
Gói biên dịch được, unit test của nó pass, và `pnpm verify` nạp được bản build
theo đúng hình dạng loader của n8n mong đợi — nhưng chưa ai nạp nó vào một
instance n8n đang chạy cả, và hai template workflow cũng vậy, chúng được kiểm
theo cấu trúc chứ không phải bằng cách import thật. Hãy coi lần cài đầu tiên của
bạn là một smoke test, và báo cho chúng tôi cái gì hỏng thay vì cho rằng đó
là lỗi của bạn.
:::

:::note Không phải tool cho AI Agent
Node cố tình không khai `usableAsTool`, nên một AI Agent không gọi được nó. Sinh
giọng nói tiêu token của tài khoản ở mọi lời gọi, và một agent gọi nó theo kiểu
dò dẫm là một trải nghiệm đầu tiên sai. Agent có bề mặt riêng được hỗ trợ:
[MCP server](./mcp/index.md).
:::

## Credential {#credential}

Một loại credential duy nhất, **VieNeu API**, hai field và không gì khác.

| Field | Tên trong workflow JSON | Bắt buộc | Mặc định |
| --- | --- | --- | --- |
| API Key | `apiKey` | Có | — (che như mật khẩu, placeholder `vn_sk_…`) |
| Base URL | `baseUrl` | Có | `https://api.vieneu.io/api/v1` |

**Tiền tố `/api/v1` trong Base URL là thật.** Controller được gắn ở `v1` nhưng
ứng dụng đặt một prefix `api` toàn cục, nên các path `/v1/...` ghi trong tài liệu
thực ra được phục vụ ở `/api/v1/...`. Một base URL `https://api.vieneu.io/v1` sẽ
404 mọi thứ. Chỉ đổi field này khi cần trỏ sang staging hay một bản triển khai tự
host; dấu gạch chéo cuối bị cắt, và một giá trị rỗng thì lùi về mặc định.

Bấm **Test** sau khi dán key. Phép test gọi `GET /voices` vào base URL đã cấu
hình — nó không tốn token và được miễn khỏi rate limiter, nhưng guard của nó vẫn
từ chối một key sai định dạng hoặc đã thu hồi bằng 401, nên một key sai sẽ hỏng
ngay tại đây thay vì lặng lẽ trả về danh mục công khai.

Key nào hợp lệ, và một key `vn_test_` làm được gì, ở đâu cũng như nhau: xem
[Xác thực](../cloud-api/overview#authentication). Điều duy nhất đáng bận tâm
trong n8n là trần 100 từ mỗi request của key `vn_test_` hiện ra dưới dạng một lỗi
**400** trên text dài, chứ không phải lỗi quota — node không kiểm prefix cũng
không đếm từ tại chỗ.

### Key không bao giờ lọt vào một biến của workflow {#the-key-never-reaches-a-workflow-variable}

Đây là lý do key là một credential chứ không phải một tham số của node:

- Tham số của node được ghi vào lịch sử thực thi, vào workflow JSON mà người ta
  dán lên issue, và vào các dòng log. Một credential thì được mã hoá khi lưu và
  tham chiếu theo id — nó không nằm trong workflow đã export.
- Không đoạn code nào trong gói đọc `apiKey` cả. Chính n8n tự dựng
  `Authorization: Bearer {{$credentials.apiKey}}`, bên trong
  `httpRequestWithAuthentication`, từ khối `authenticate` của credential. Không
  có biến nào giữ key để một expression hay một dòng log chạm tới được.
- Mọi thông báo lỗi, mô tả và response body đính kèm đều đi qua một bộ redact
  trước. Bearer token và bất cứ thứ gì có dạng `vn_sk_…`, `vn_test_…` hay `sk_…`
  đều thành `***REDACTED***` trước khi n8n lưu chúng vào dữ liệu thực thi.

## Resource và operation {#resources-and-operations}

| Resource | Operation | Gọi | Tham số |
| --- | --- | --- | --- |
| Speech | Generate | `POST /audio/speech` hoặc `POST /tts` + poll | Text, Engine, Voice, Put Output File in Field, Options |
| Job | Get | `GET /tts/{jobId}` | Job ID, Download Audio, Put Output File in Field |
| Voice | Get Many | `GET /voices` | Engine, Search, Return All, Limit |
| Engine | Get Many | `GET /engines` | không có |

Engine: Get Many không có tham số riêng nào. Nó trả về một item cho mỗi engine,
gồm cả `billingMultiplier` **đang chạy** — hãy đọc cái đó thay vì đọc bất kỳ bảng
nào, kể cả ảnh chụp trong [Engine](../cloud-api/overview#engines).

### Những gì node không làm {#what-the-node-does-not-do}

Không phải mọi thứ API cung cấp đều được phơi ra, và các phần bỏ đi là có chủ ý:
một operation tiêu tiền thật hoặc cần upload file là thứ tệ hại nếu một workflow
chạm phải do vô ý, mà một Loop Over Items thì chạm nhiều lần.

| Thiếu | Vì sao |
| --- | --- |
| **Nhân bản giọng** (`POST /voices`, `/clone`, `/prepare`, `/upload`) | Một khoản phí cố định 5000 token trước mọi hệ số (15000 trên v4) cho một clip, một lần upload multipart, và một suất trừ vào hạn mức clone của gói. Những giọng nhân bản bạn *đã* có thì vẫn chọn được trong danh sách Voice; bạn chỉ không tạo mới được từ một workflow. |
| **`DELETE /voices/:voiceId`** | Phá huỷ dữ liệu, và không hoàn tác được từ bên trong một workflow. |
| **Lồng tiếng và SRT** (`/dub`, `/srt`) | Cả hai đều có hình dạng upload, cả hai đều tính phí theo lời gọi. |
| **`POST /dialogue`** | Chỉ cần key và không phải upload, nhưng 50 lượt × 5000 ký tự là không có trần nào trong request schema — mức rủi ro lớn nhất trong một lời gọi đơn lẻ trên cả bề mặt này, hơn hẳn phần còn lại. |
| **Streaming** (`POST /tts/stream`) | Phản hồi của nó là một định dạng khung có tiền tố độ dài riêng, lẫn cả khung heartbeat vào giữa; n8n không có bộ parse khung nào và sẽ trả lại cho bạn một buffer mù. Nó cũng chặn ở 4 stream đồng thời cho mỗi key, và mọi stream đều tính phí theo hệ số v4 bất kể engine gửi lên là gì. Xem [Streaming](../cloud-api/streaming) nếu bạn cần bề mặt đó. |

### Chọn giọng {#picking-a-voice}

Field Voice là một resource locator với hai chế độ:

- **From List** — tìm kiếm được, dựa trên `GET /voices`. Việc lọc diễn ra ngay
  trong node, vì API không có tham số search: nó bỏ dấu (kể cả `đ`/`Đ`) và đòi
  mọi từ đều phải khớp với id, name, description, gender, region, engine và kind.
  Các dòng đọc là `Name — gender · region · engine (id)`, kèm `· your clone` bên
  trong nhóm facet cho **bất kỳ** giọng nào API trả về với `kind: "cloned"` —
  clone của chính bạn lẫn clone do admin công bố, nên một clone công khai do
  người khác tạo cũng bị gắn nhãn như vậy. Danh sách phân trang 250 một lần.
- **By ID** — dành cho expression. Chép id thật chính xác, kể cả dấu tiếng Việt.

Đặt **Engine** trước sẽ giới hạn danh sách lại, và ở Speech: Generate danh sách
nạp lại khi Engine đổi. Hãy làm vậy: một giọng chỉ dựng được trên đúng engine của
nó và không bao giờ được thay bằng giọng khác — lệch là một lỗi 400 cứng. Ô chọn
này nạp trực tiếp từ `GET /engines`, nên từ khi `v3` ngừng ngày 2026-09-24 nó chỉ
còn mỗi `v4`; trường hợp lệch duy nhất còn lại là một slug `v3` cũ dán vào By ID.

:::warning Giọng `clone_…` không chạy trên text ngắn
Route text ngắn mà node chọn mặc định, `POST /audio/speech`, từ chối thẳng mọi
giọng nhân bản — phép kiểm giọng của nó chạy mà không có user id, nên nó không
phân biệt được clone của bạn với clone của bất kỳ ai và từ chối tất cả bằng một
lỗi 400 ghi *"Cloned voice … can only be used on POST /v1/tts or POST
/v1/tts/stream."* Điều này áp dụng cho cả clone do admin công bố.

Route text dài là `POST /tts`, và route đó *có* nhận clone — cả của bạn lẫn các
clone đã công bố. Vậy nên để dùng một giọng `clone_…` từ node, hãy giữ item
**vượt** ngưỡng Synchronous Route Threshold, hoặc đặt **Options → Synchronous
Route Threshold** về `1` để ép mọi item đi đường async.

Đừng nghe lời khuyên kèm lỗi 400 của node ở đây. Nó nói nguyên nhân thường gặp là
một giọng thuộc engine kia, điều đó đúng với giọng trong danh mục nhưng chẳng
liên quan gì đến chuyện này.
:::

Id không mang tính mô tả. Nhiều id là slug khó đọc như `vieneu-2-000494` mà giọng
lại tên là `Minh Đức`, và vài id thẳng thừng là tên một người chẳng liên quan.
Đừng bao giờ suy ra giới tính hay danh tính từ một id.

## Speech: Generate {#speech-generate}

| Tham số | Kiểu | Mặc định | Ghi chú |
| --- | --- | --- | --- |
| Text | string (4 dòng) | — | Bắt buộc. Tính phí theo ký tự với sàn 50 ký tự — xem [Tính phí](../cloud-api/overview#billing). |
| Engine Name or ID | options | mặc định của tài khoản | Để trống = mặc định của tài khoản quyết định, và nó cũng quyết định luôn đơn giá. |
| Voice | resource locator | mặc định của engine | Giới hạn theo Engine. |
| Put Output File in Field | string | `data` | Bắt buộc. Binary property mà audio rơi vào. |
| Options | collection | `{}` | Chín option, bên dưới. |

### Options {#options}

| Option | Tên | Mặc định | Ghi chú |
| --- | --- | --- | --- |
| AI Refine | `aiRefine` | `false` | Chạy chuẩn hoá và kiểm duyệt bằng AI trước. Tốn một khoản phụ phí trên số ký tự và thêm độ trễ. Không route `/v1` nào báo lại khoản phụ phí đó — help text của node dẫn con số 1.3× tại thời điểm viết. Đây cũng là thứ duy nhất có thể sinh ra một lỗi 422. |
| Emotion Name or ID | `emotion` | mặc định của engine | Nạp từ `GET /emotion-tags` cho engine đang chọn. Tuỳ engine: v4 không có kiểu đọc nào, nên danh sách ở đó luôn rỗng. |
| File Name | `fileName` | suy ra | Để trống thì tên được suy ra từ định dạng mà API thực sự tạo ra. |
| Format | `format` | `wav` | `wav`, `mp3`, `opus`, `pcm`, `ulaw`. |
| Download Audio | `downloadAudio` | `true` | Chỉ áp cho route text dài — xem bên dưới. |
| Job Timeout (Seconds) | `jobTimeout` | `600` | Tối thiểu 5. Poll một job text dài trong bao lâu. |
| Poll Interval (Seconds) | `pollInterval` | `2` | Tối thiểu 0.5, và bị kẹp về 500 ms trong code bất kể workflow JSON ghi gì. |
| Speed | `speed` | `1` | 0.5–2.0. |
| Synchronous Route Threshold | `syncMaxChars` | `500` | Tối thiểu 1. Chỗ hai route tách đôi. |

Node không gửi sample rate nào và cũng không phơi ra option nào cho nó.

:::tip Có thể bạn chẳng cần đến Job Timeout
Job Timeout tồn tại vì node phải poll. Nếu job của bạn đủ dài để cái timeout đó
thành rủi ro thật, hãy đăng ký một webhook thay vào đó: VieNeu POST một sự kiện
có chữ ký khi job chạm trạng thái kết thúc, và một node trigger **Webhook** của
n8n nhận nó. `POST /tts` — route mà option này chi phối — là route duy nhất sinh
ra những sự kiện đó. Xem [Webhooks](../cloud-api/webhooks).
:::

### Hai route, chọn theo độ dài text {#two-routes-chosen-by-text-length}

| | Text ngắn | Text dài |
| --- | --- | --- |
| Điều kiện | độ dài ≤ `syncMaxChars` (mặc định 500) | dài hơn |
| Endpoint | `POST /audio/speech` (chặn) | `POST /tts`, rồi poll `GET /tts/{jobId}` |
| Field trong body | `input`, `response_format`, `speed`, và `voice` / `engine` / `emotion` / `aiRefine` khi có đặt | `text`, `speed`, và `voiceId` / `engine` / `emotion` / `aiRefine` khi có đặt |
| Định dạng | cả năm | chỉ WAV — route này không có tham số format |
| Giọng nhân bản | bị từ chối bằng 400 | được nhận (của bạn và clone do admin công bố) |
| `Download Audio` | bị bỏ qua; byte về thẳng trong phản hồi | có tác dụng |
| JSON ra thêm | `sampleRate`, `requestId` | `jobId`, `duration`, `audioUrl`, `audioUrlExpiresIn` |

`format` và `mimeType` được đặt trên **cả hai** route và được liệt kê cùng các
field dùng chung [bên dưới](#output) — đừng coi sự có mặt của chúng là dấu hiệu
cho biết một item đã đi route nào. Hãy đọc `route` cho việc đó.

Để ý là tên field khác nhau giữa hai route (`input`/`voice` so với
`text`/`voiceId`). Node lo chuyện đó; nó chỉ quan trọng nếu bạn chép một body qua
lại giữa node và một HTTP Request node.

Nâng `syncMaxChars` lên nghĩa là bắt node giữ một kết nối HTTP — và một suất
worker của n8n — mở suốt cả quá trình tổng hợp.

Audio hoàn chỉnh trên route text dài được tải về từ một URL S3 presigned
**không** kèm xác thực. S3 từ chối một request presigned nếu nó còn mang thêm
header `Authorization`, nên riêng lời gọi đó cố tình bỏ qua credential.

### Những phép kiểm chạy trước khi bị tính bất cứ đồng nào {#checks-that-run-before-anything-is-billed}

Các trường hợp này hỏng ngay tại chỗ, không request nào được gửi đi và không tốn
đồng nào:

- text rỗng;
- text trên 50000 ký tự;
- speed ngoài khoảng 0.5–2.0;
- một định dạng không phải WAV đi kèm text vượt ngưỡng. Route text dài chỉ trả về
  được WAV, nên trường hợp này bị từ chối chứ không âm thầm hạ cấp.

### Output {#output}

JSON của mỗi item mang:

| Field | Ý nghĩa |
| --- | --- |
| `route` | `sync` hoặc `async` — cách duy nhất đáng tin để biết route nào đã chạy |
| `engine`, `voiceId` | thứ đã được yêu cầu, hoặc `null` nếu dùng mặc định |
| `textLength` | số ký tự thô |
| `billedCharacterBasis` | số ký tự theo NFC với sàn 50 ký tự |
| `billingNote` | nói rõ rằng con số cơ sở này là trước hệ số engine và phụ phí AI refine |
| `format` | định dạng thực sự được tạo ra; luôn là `wav` trên route text dài |
| `mimeType` | đọc từ phản hồi, không phải từ request — xem bên dưới |

`billedCharacterBasis` là **cơ sở** của khoản phí, không phải tổng số token.
Backend áp bốn hệ số theo đúng thứ tự này: cơ sở ký tự (sàn 50 ký tự), rồi phụ
phí AI refine nếu nó bật, rồi hệ số engine, rồi một **hệ số token toàn cục** —
một thiết lập của quản trị viên, mặc định là 1, mà không route `/v1` nào báo lại.
Chính hệ số cuối đó là lý do không con số nào node in ra là một báo giá. Hãy đọc
hệ số engine trực tiếp từ Engine: Get Many, và coi mọi ngân sách bạn tính ra chỉ
là ước lượng.

## Audio ra ở đâu {#where-the-audio-comes-out}

Audio được gắn vào binary property có tên do **Put Output File in Field** đặt —
là `data` nếu bạn không đổi. JSON của cùng item đó cũng có thêm `fileName` và
`fileSize`.

Hãy đổi tên field khi một node phía trên đã chiếm `data`, hoặc khi một node phía
sau cần một tên cụ thể.

Mime type và tên file được đọc từ **phản hồi**, không bao giờ từ request:

1. header `X-Output-Format`, nếu nó nêu một định dạng đã biết;
2. nếu không thì `Content-Type`;
3. tên file lấy từ `Content-Disposition` khi có, nếu không thì `speech.<ext>` —
   `wav`, `mp3`, `ogg` cho Opus, và `raw` cho `pcm` và `ulaw` vốn không có
   header.

Sự gián tiếp đó là có chủ ý: route text ngắn có thể lùi về WAV khi gặp một worker
cũ, và các header này là dấu hiệu duy nhất cho biết điều đó đã xảy ra. Gắn nhãn
cho binary theo request sẽ đưa cho node phía sau một mớ byte WAV mang nhãn
`audio/mpeg`.

Trên route text dài, tên mặc định là `speech-<jobId>.wav`.

**Đưa cho node kế tiếp.** Mọi node tiêu thụ file đều hỏi binary property theo
tên: đưa nó `data`, hoặc tên bạn đã đổi thành. Kích thước và tên cũng nằm trên
JSON của item dưới dạng `{{ $json.fileSize }}` và `{{ $json.fileName }}`, nên một
IF phía sau kiểm được chúng mà không đụng vào byte nào. Để giữ một file WAV lớn
khỏi bộ nhớ workflow khi bạn chỉ cần cái link, hãy tắt **Download Audio** trên
route text dài và chuyền `{{ $json.audioUrl }}` đi tiếp — nó còn hiệu lực trong
một khoảng thời gian giới hạn, và JSON báo lại khoảng đó ở `audioUrlExpiresIn`.

## Lỗi hiện ra thế nào {#how-failures-surface}

Mọi thất bại từ API đều thành một `NodeApiError` với thông báo:

```text
VieNeu API error 403 while synthesizing speech — <detail> (request id <x-request-id>)
```

Phần văn xuôi nằm ở `description`. Chỗ để rẽ nhánh là `failure.cause` máy đọc
được của n8n. Mỗi status nghĩa là gì ở phía API thì xem
[Lỗi](../cloud-api/overview#errors); bảng dưới đây là phần ánh xạ mà node thêm
vào bên trên đó:

| Status | `failure.cause` | Chuyện gì đã xảy ra |
| --- | --- | --- |
| 400 | `configuration-invalid` | Thường là một giọng thuộc engine kia — nhưng cũng có thể là một giọng `clone_…` trên route text ngắn, text không phải tiếng Việt, và một key `vn_test_` vượt trần 100 từ của nó. |
| 401 | `credential-invalid` | Key bị từ chối. Dán một key còn hiệu lực vào credential. |
| 403, thông báo có nhắc tới một engine | `configuration-invalid` | Gói của bạn không bao gồm engine đó. |
| 403, các trường hợp còn lại | `quota-exhausted` | Token grant đã cạn hoặc hết hạn. **Không gợi ý thời gian chờ nào được đặt cả** — nó không tự hết. |
| 404 | `configuration-invalid` | Không có gì dưới id đó. Một jobId của key khác đọc lên y hệt một jobId chưa từng tồn tại; một giọng đã bị gỡ trả về 404, không phải 400. |
| 413 | `configuration-invalid` | Payload quá lớn. |
| 422 | `configuration-invalid` | Kiểm duyệt từ chối văn bản. Chỉ chạm tới được khi bật AI Refine. Hãy viết lại; thử lại y nguyên thì vẫn hỏng. |
| 429 | xem bên dưới | Ba bộ giới hạn khác nhau. |
| 503 | `temporarily-unavailable` | Không có worker nào rảnh cho engine đó. Tạm thời thôi. |
| 5xx khác | `temporarily-unavailable` | Thử lại sau chốc lát. |
| mọi thứ khác | `node-defect` | Phản hồi ngoài dự kiến. |

VieNeu trả **403** khi hết token — không bao giờ 402. Logic retry bám vào 402 sẽ
không bao giờ chạy.

Các thất bại về DNS, TLS, reset và timeout không bao giờ vào tới bảng đó; chúng
thành một lỗi `temporarily-unavailable` ghi "Could not reach VieNeu while …", với
phần mô tả bảo bạn kiểm tra xem Base URL có kèm `/api/v1` hay không.

### Bị rate limit hay đã cạn quota {#rate-limited-versus-quota-exhausted}

Một lỗi 429 có thể đến từ ba nơi đòi hỏi ba cách phản ứng trái ngược nhau. Node
đọc header của phản hồi để phân biệt — và đó là lý do nó tự soi status thay vì để
n8n ném lỗi ra rồi làm mất chúng.

| Dấu hiệu trên phản hồi | Bộ giới hạn nào | `failure.cause` | Gợi ý chờ | Phải làm gì |
| --- | --- | --- | --- | --- |
| Có `Retry-After` | Throttler của ứng dụng, đếm **theo từng API key** | `rate-limited` | `retryAfterMs`, lấy từ header | Chờ đúng khoảng thời gian nêu ra rồi thử lại. Số dư không có vấn đề gì. |
| Không có `Retry-After`, body khớp `token limit reached` / `resets at` / `quota` | **Hạn ngạch token** — một trần theo ngày hoặc theo tuần trên grant | `quota-exhausted` | `resetsAtEpochMs`, parse ra từ nội dung thông báo | Đừng thử lại. Chờ tới mốc reset đã nêu, hoặc nạp thêm. |
| Không có `Retry-After`, cũng không có câu chữ đó (HTML của nginx) | **Biên nginx**, đếm theo IP nguồn | `rate-limited` | không có | Lùi lại thật mạnh. Trên n8n Cloud, IP đó dùng chung với những khách hàng chẳng liên quan gì tới bạn. |

Hai trong ba trường hợp gộp về `rate-limited`. Phân biệt chúng bằng việc
`retryAfterMs` có được đặt hay không: có nghĩa là throttler của ứng dụng và một
khoảng chờ đã biết; không có nghĩa là biên và chẳng có gợi ý chờ nào cả.

Mốc reset của quota phải parse ra từ văn xuôi vì không còn cách nào khác:
controller chỉ giữ lại chuỗi thông báo, nên đoạn text đó là dấu vết cuối cùng còn
sót lại của field `resetAt` có cấu trúc.

Để ý là hai status khác nhau cùng ánh xạ về `quota-exhausted`: lỗi 429 ở trên, có
kèm mốc reset, và lỗi 403 do cạn grant, chẳng kèm gì.

### Những thất bại không phải là thất bại HTTP {#failures-that-are-not-http-failures}

- **Một job hỏng trả về HTTP 200** kèm `status: "failed"`. Speech: Generate nâng
  nó lên thành lỗi; Job: Get trả nó về như dữ liệu, nên một vòng lặp Wait + IF
  nhìn thấy được trạng thái kết thúc và thoát ra được.
- **Poll hết giờ** sinh ra một lỗi hình dạng 504 nói rằng job vẫn đang chạy và
  **đã bị tính phí rồi**, kèm theo `jobId` của nó. Hãy lấy nó về sau bằng Job:
  Get. Giữ Job Timeout thấp hơn timeout thực thi của chính n8n, nếu không n8n
  giết cả lần chạy và jobId đi theo luôn.
- **Body audio rỗng**, hoặc một lần submit không trả về `jobId`, sinh ra một lỗi
  hình dạng 502.
- **Một phản hồi 200 với danh sách giọng rỗng và một field `error`** — tức là lần
  đọc danh mục thất bại — được nâng lên thành lỗi thay vì hiện ra một dropdown
  rỗng.

Khi node được đặt tiếp tục chạy khi lỗi thay vì dừng lại, thất bại trở thành JSON
của item: `error` với thông báo đã redact, cộng `jobId` **chỉ khi một job đã thực
sự được gửi đi**. Trên item đó không có gì khác — đặc biệt là không có binary
nào. Một thất bại ở text ngắn, và một thất bại async xảy ra ngay lúc submit, mang
theo `error` mà chẳng có `jobId` nào cả.

## Workflow ví dụ: đọc text dài, cứu lại những job hết giờ {#example-workflow-narrate-long-text-recover-the-ones-that-time-out}

Bảy phút audio có thể dài hơn cả cửa sổ poll, mà job thì bị tính phí ngay lúc
submit. Workflow này giữ lại những job đó thay vì trả tiền để lấy về con số
không.

Nếu bạn nhận được request HTTPS đi vào, một [webhook](../cloud-api/webhooks) cùng
một node trigger **Webhook** của n8n tránh được toàn bộ vấn đề này — không còn
cửa sổ poll nào để mà vượt quá. Chỉ dựng cái này khi bạn không làm được như vậy.

1. **Manual Trigger** — "When clicking 'Execute workflow'". Hãy thay nó bằng thứ
   sinh ra item của bạn; mỗi item cần một field `text`.
2. **VieNeu** — Resource *Speech*, Operation *Generate*.
   - **Text**: `={{ $json.text }}`
   - **Engine**: chọn một cái, để danh sách giọng được giới hạn theo nó.
   - **Voice**: *From List*, tìm giọng bạn muốn.
   - **Put Output File in Field**: `data`
   - **Options → Format**: `WAV` (text dài không trả về gì khác).
   - **Options → Job Timeout (Seconds)**: `900`, thấp hơn timeout thực thi n8n
     của bạn.
   - Mở tab **Settings** của node và đổi **On Error** từ *Stop Workflow* sang lựa
     chọn tiếp tục bằng **output thường** của node — không phải lựa chọn thêm một
     connector output lỗi riêng, vì cái đó sẽ đẩy các item hỏng xuống một nhánh
     mà IF ở bước 3 không bao giờ nhìn thấy. Không làm vậy thì một job hết giờ sẽ
     huỷ cả lần chạy và jobId mất theo.
3. **IF** — "Failed?". Điều kiện: `={{ $json.error }}` *is not empty*. True nghĩa
   là có gì đó hỏng; false nghĩa là item đã có audio ở `data`.
4. **Nhánh true → IF** — "Recoverable?". Điều kiện: `={{ $json.jobId }}` *is not
   empty*. True nghĩa là job đã bị tính phí và nhiều khả năng vẫn đang chạy.
   False nghĩa là thất bại xảy ra trước khi có job nào — key sai, giọng sai, một
   lỗi 429 — nên chẳng có gì để lấy về: hãy đẩy nó ra một output lỗi, một tin
   nhắn Slack, hay bất cứ đâu bạn muốn nhìn thấy nó. **Đừng** gộp nó vào bước 8;
   nó không có binary và sẽ không bao giờ có.
5. **Recoverable → Wait** — 60 giây. Poll không tốn token và không tốn ngân sách
   rate limit, nhưng ở biên thì nó không miễn phí.
6. **Wait → VieNeu** — Resource *Job*, Operation *Get*.
   - **Job ID**: `={{ $json.jobId }}`
   - **Download Audio**: bật (mặc định nó tắt trên operation này)
   - **Put Output File in Field**: `data`
7. **IF** — "Terminal?". Điều kiện: `={{ $json.status }}` bằng `completed`. True
   → bước 8. False → một IF trên `={{ $json.status }}` bằng `failed`, nhánh true
   của nó kết thúc nhánh (một node No Operation) còn nhánh false thì quay **về
   node Wait** ở bước 5. Một job đã vượt quá 900 giây poll thì không chắc chắn là
   xong sau 60 giây nữa, và nếu không có vòng lặp này thì item rơi tuột qua mà
   chẳng có binary nào đính kèm.
8. Gộp các item đã hoàn tất — những cái từ nhánh false của bước 3 và những cái từ
   nhánh true của bước 7 — vào thứ sẽ tiêu thụ file: upload, email, lưu trữ. Cả
   hai đều mang cùng tên binary property, nên các node phía sau không cần biết
   một item đã đi đường nào.

Cùng hình dạng đó chạy được theo từng item trên một bảng tính. Nhớ rằng mỗi dòng
là một lần tổng hợp bị tính phí riêng.

## Không muốn cài node? {#dont-want-to-install-the-node}

Mọi operation đều là một lời gọi HTTPS, và node **HTTP Request** có sẵn của n8n
gọi được tất cả. Đường này chạy trên n8n Cloud, không cần bước build, và không
cần gì từ VieNeu ngoài một key.

Hai workflow làm sẵn đi kèm gói, nằm dưới `n8n-nodes-vieneu/templates/`:

| File | Dựng ra cái gì |
| --- | --- |
| `vieneu-speech-http-request.json` | Manual trigger → một HTTP Request tới `POST /audio/speech` → MP3 trên binary field `data`. |
| `vieneu-long-text-http-request.json` | Submit tới `POST /tts`, rồi Wait → poll → IF cho tới khi job kết thúc, rồi tải audio presigned về. |

Nhập cái nào cũng được bằng *Workflows → Import from File*. Cả hai đều gắn key
qua một credential Bearer Auth, không bao giờ qua một header gõ tay vào tham số
node. Lưu ý là chưa cái nào được chạy thật trên một n8n đang chạy — chúng được
kiểm theo cấu trúc.

Nếu bạn không có gói, các công thức bên dưới dựng lại đúng hai workflow đó bằng
tay. Về hợp đồng request nằm dưới, xem
[Tổng quan Cloud API](../cloud-api/overview).

Tìm một voice id trước. Cả hai lời gọi dưới đây đều không cần key. Từ 2026-09-24,
Cloud API chỉ còn một engine, V4, với id là tên hiển thị (`Ngọc Lan`); các slug
`vieneu-…` của danh mục V3 đã ngừng không còn tra ra nữa và trả 400 ở `voiceId`,
nên hãy nạp lại bất kỳ id nào bạn lưu từ trước đó.

```bash
# Which engine is the default? Look for "isDefault": true.
curl -s https://api.vieneu.io/api/v1/engines

# Then list that engine's voices only.
curl -s "https://api.vieneu.io/api/v1/voices?engine=v4" | head -c 400
```

Kiểm tra key của bạn chạy được, và nghe thử kết quả, trước khi nối dây bất cứ thứ
gì:

```bash
curl -X POST https://api.vieneu.io/api/v1/audio/speech \
  -H "Authorization: Bearer $VIENEU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"input":"Xin chào Việt Nam.","response_format":"mp3","speed":1}' \
  --output speech.mp3
```

### Text ngắn: một node HTTP Request {#short-text-one-http-request-node}

| Thiết lập | Giá trị |
| --- | --- |
| Method | `POST` |
| URL | `https://api.vieneu.io/api/v1/audio/speech` |
| Authentication | Generic Credential Type → **Bearer Auth**, giữ key `vn_sk_…` của bạn |
| Send Body | bật, JSON |
| Response → Format | **File**, output property `data` |

Body, viết dưới dạng expression để text lấy được từ item đi vào:

```js
{{ JSON.stringify({
  input: $json.text || 'Xin chào Việt Nam! Đây là giọng đọc tiếng Việt của VieNeu.',
  voice: $json.voiceId || undefined,
  response_format: 'mp3',
  speed: 1
}) }}
```

Dùng một **credential Bearer Auth**, đừng gõ header `Authorization` thẳng vào
node. Credential được tham chiếu theo id và không nằm trong workflow JSON đã
export — đó là khác biệt giữa một workflow bạn dán được lên issue và một workflow
làm lộ key của bạn ngay khi bạn dán.

`response_format` ở đây nhận `mp3`, `wav`, `opus`, `pcm`, `ulaw`. Route này chặn
suốt cả quá trình tổng hợp, nên hãy để dành nó cho text ngắn. Một giọng `clone_…`
bị từ chối ở đây bằng một lỗi 400, y như khi đi qua node.

### Text dài: submit, poll, tải về {#long-text-submit-poll-download}

Quá khoảng 500 ký tự, hãy xếp job vào hàng đợi thay vì giữ một kết nối mở.

Trước khi bạn dựng vòng lặp: nếu n8n của bạn nhận được request HTTPS đi vào, một
[webhook](../cloud-api/webhooks) thay các bước 2 đến 5 bằng một node trigger
**Webhook** duy nhất. VieNeu POST một sự kiện có chữ ký ngay khi job chạm trạng
thái kết thúc, và `POST /tts` là route duy nhất sinh ra những sự kiện đó. Chỉ
poll khi bạn không có sẵn một endpoint nhận vào.

Bản dùng poll, khớp với `vieneu-long-text-http-request.json`, gồm tám node: một
Manual Trigger và bảy node dưới đây.

1. **HTTP Request — "Submit TTS job"**: `POST https://api.vieneu.io/api/v1/tts`,
   credential Bearer Auth, body JSON:

   ```js
   {{ JSON.stringify({
     text: $json.text,
     voiceId: $json.voiceId || undefined,
     speed: 1
   }) }}
   ```

   Để ý tên field trên route này: `text` và `voiceId`, không phải `input` và
   `voice`. Nó không nhận tham số format nào và luôn trả về WAV. Nó phản hồi kèm
   một `jobId`, và text bị tính phí **ngay tại đây**, trước khi job được xếp
   hàng.
2. **Wait** — 3 giây.
3. **HTTP Request — "Check job status"**:
   `GET https://api.vieneu.io/api/v1/tts/{{ $json.jobId }}` (dưới dạng
   expression), cùng credential Bearer Auth đó.
4. **IF — "Completed?"**: `={{ $json.status }}` bằng `completed`. True → tải về.
   False → bước 5.
5. **IF — "Failed?"**: `={{ $json.status }}` bằng `failed`. True → bước 6. False
   → quay lại node **Wait**. Một job hỏng trả về HTTP **200**, nên hãy rẽ nhánh
   theo body, đừng bao giờ theo mã trạng thái.
6. **No Operation — "Job failed"**: kết thúc nhánh hỏng.
7. **HTTP Request — "Download audio"**: `GET {{ $json.audioUrl }}`, Response
   Format **File**, output property `data`, và **không credential, không xác
   thực**. URL đó là một link S3 presigned mang chữ ký của chính nó — S3 từ chối
   thẳng request nếu có một header `Authorization` đi kèm.

Lỗi trên đường này về ở dạng thô, không có phần phân loại của node. Các quy tắc
vẫn giữ nguyên: một lỗi 429 kèm `Retry-After` là throttler theo key; một lỗi 429
mà thông báo nhắc tới trần token hoặc một mốc reset là hạn ngạch; một lỗi 429
không có cả hai thì đến từ biên và được đếm theo IP nguồn. Hết sạch token thì là
một lỗi 403.
