# n8n node

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

:::info Release status: built, not yet published
The VieNeu community node is finished and tested, but **the package has not been
released publicly**. It is marked `private: true` in its `package.json` and has
never been pushed to npm, so there is nothing for you to install today from a
public registry.

If you want to run it now, **contact VieNeu** and ask for the package — we can
hand you a build. Watch the
[Cloud API changelog](../cloud-api/changelog) for the announcement when it
reaches npm; the [Install](#install) section below has both paths.

Everything else on this page — the operations, parameters, output fields and
error behaviour — is accurate against the built package, so you can decide
whether the node is what you want before asking for it.
:::

VieNeu ships an n8n community node — one node, four resources, audio out as a
binary field. It is a **programmatic node**, not a trigger: it sits in the middle
of a workflow and turns Vietnamese text into a file the next node can send,
store or upload.

There are two ways to call VieNeu from n8n:

- **The node** — a voice picker with search, automatic routing between the short
  and long text endpoints, and API errors mapped onto n8n's machine-readable
  `failure.cause`. Needs the package (see above) and a self-hosted n8n.
- **Plain HTTP Request nodes** — nothing to install, works on n8n Cloud, and
  available to everyone today. See
  [Don't want to install the node?](#dont-want-to-install-the-node) at the
  bottom. Two importable templates ship with the package.

:::caution Do not `npm install n8n-nodes-vieneu`
The name is **unclaimed on npm**, so whatever that command resolves to today is
not this package. Do not install it and do not paste it into n8n's community-node
dialog.
:::

## Install

Self-hosted n8n only. n8n Cloud installs verified packages from npm, so the
community-node path is unavailable there whatever happens — use the
[HTTP Request route](#dont-want-to-install-the-node) instead.

### When the package is published

This is the shape the install will take. **None of it works yet** — the package
is not on npm:

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

Nothing below will be needed then. Check the
[changelog](../cloud-api/changelog) before following the manual path.

### Today

Ask VieNeu for the package. What arrives is the source directory
`n8n-nodes-vieneu/`, which you build yourself; the repository it lives in is
private, so there is no `git clone` to give you.

Prerequisites:

| Requirement | Where it comes from |
| --- | --- |
| **Node 20.19 or newer** | `engines.node: ">=20.19"` in the package's `package.json` |
| **pnpm** — `corepack enable` is enough | the package pins `packageManager: "pnpm@10.24.0"`; npm and yarn are not substitutes here (see below) |
| **n8n bundling `n8n-workflow` 2.37.1 or newer** | the version the package is compiled against, and the source imports `NodeConnectionTypes`, which older releases do not export |

n8n does not publish a plain "minimum version" number for this; check what your
instance actually bundles with `npm ls n8n-workflow` where n8n is installed. The
package declares `n8nNodesApiVersion: 1`. On an n8n too old to export
`NodeConnectionTypes` the node does not appear in the panel at all; on one too
old for the `Failure` type, the `failure.cause` branching documented
[below](#how-failures-surface) does nothing.

Build it:

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

`pnpm build` is mandatory. `dist/` is git-ignored, and `dist/` is exactly what
the `n8n` block in `package.json` points n8n at — a fresh copy has none.

`pnpm verify` is the check worth running. It `require()`s the built `dist/` the
way n8n's loader does and instantiates the exported class, which catches the
three failures that otherwise show up only as a node that silently never appears
in the panel: a `package.json` path that no longer matches the emitted layout, an
icon `tsc` did not copy, and a class n8n cannot construct.

Use `pnpm`, not `npm install`. The package sits outside the repository's own
workspace and carries an empty `pnpm-workspace.yaml` to make its directory a
workspace root; without pnpm reading that file, an install run from here walks up
to the repository root and installs nothing at all, exiting 0 with nothing to
build from.

Then either copy the build in, or point n8n at the package directory:

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

Two mistakes that cost an afternoon:

- `~/.n8n/custom` is **not** `~/.n8n/nodes`. The latter holds npm-installed
  community nodes; a private build placed there is ignored.
- `N8N_CUSTOM_EXTENSIONS` takes a **semicolon**-separated list of absolute paths
  on every platform. A colon-separated list does not error — it loads nothing.

For Docker, mount the built package into the container and set that same
variable to the in-container path.

The node should then appear as **VieNeu** in the node panel. Its full type is
`n8n-nodes-vieneu.vieneu`.

:::caution This install path has not been exercised against a live n8n
The package compiles, its unit tests pass, and `pnpm verify` loads the built
output in the shape n8n's loader expects — but nobody has yet loaded it into a
running n8n instance, and the same is true of the two workflow templates, which
are validated structurally rather than by importing them. Treat your first
install as a smoke test, and tell us what breaks rather than assuming it is
something you did.
:::

:::note Not an AI Agent tool
The node deliberately does not declare `usableAsTool`, so an AI Agent cannot
call it. Speech generation spends the account's tokens on every call, and an
agent invoking it speculatively is the wrong first experience. Agents have their
own supported surface: the [MCP server](./mcp/index.md).
:::

## Credential

One credential type, **VieNeu API**, with two fields and nothing else.

| Field | Name in workflow JSON | Required | Default |
| --- | --- | --- | --- |
| API Key | `apiKey` | Yes | — (password-masked, placeholder `vn_sk_…`) |
| Base URL | `baseUrl` | Yes | `https://api.vieneu.io/api/v1` |

**The `/api/v1` prefix in the Base URL is real.** The controller is mounted at
`v1` but the application sets a global `api` prefix, so the documented `/v1/...`
paths are served at `/api/v1/...`. A base URL of `https://api.vieneu.io/v1`
404s on everything. Change this field only to point at staging or a self-hosted
deployment; trailing slashes are trimmed, and an empty value falls back to the
default.

Press **Test** after pasting a key. The test issues `GET /voices` against the
configured base URL — it costs no tokens and is exempt from the rate limiter, but
its guard still rejects a malformed or revoked key with 401, so a wrong key fails
here instead of quietly returning the public catalogue.

Which keys are valid, and what a `vn_test_` key can do, is the same everywhere:
see [Authentication](../cloud-api/overview#authentication). The one thing that
matters in n8n is that a `vn_test_` key's 100-word-per-request cap surfaces as a
**400** on long text, not as a quota error — the node does not check the prefix
or the word count locally.

### The key never reaches a workflow variable

This is the reason the key is a credential rather than a node parameter:

- Node parameters are written into execution history, into the workflow JSON
  people paste into issues, and into log lines. A credential is encrypted at
  rest and referenced by id — it is not part of an exported workflow.
- No code in the package ever reads `apiKey`. n8n builds
  `Authorization: Bearer {{$credentials.apiKey}}` itself, inside
  `httpRequestWithAuthentication`, from the credential's `authenticate` block.
  There is no variable holding the key that an expression or a log line could
  reach.
- Every error message, description and attached response body is run through a
  redactor first. Bearer tokens and anything shaped like `vn_sk_…`, `vn_test_…`
  or `sk_…` become `***REDACTED***` before n8n stores them in execution data.

## Resources and operations

| Resource | Operation | Calls | Parameters |
| --- | --- | --- | --- |
| Speech | Generate | `POST /audio/speech` or `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` | none |

Engine: Get Many has no parameters of its own. It returns one item per engine,
including the **live** `billingMultiplier` — read that rather than any table,
including the snapshot in [Engines](../cloud-api/overview#engines).

### What the node does not do

Not everything the API offers is exposed, and the omissions are deliberate: an
operation that spends real money or needs a file upload is a bad thing for a
workflow to reach by accident, and a Loop Over Items reaches things many times.

| Missing | Why |
| --- | --- |
| **Voice cloning** (`POST /voices`, `/clone`, `/prepare`, `/upload`) | A flat 5,000-token charge before any multiplier (15,000 on v4) for one clip, a multipart upload, and a slot against the plan's clone limit. Clones you already own *are* selectable in the Voice list; you just cannot create one from a workflow. |
| **`DELETE /voices/:voiceId`** | Destructive, and irreversible from inside a workflow. |
| **Dubbing and SRT** (`/dub`, `/srt`) | Both upload-shaped, both billed per call. |
| **`POST /dialogue`** | Key-only and no upload, but 50 turns × 5,000 characters is uncapped in the request schema — by a wide margin the largest single-call exposure on the surface. |
| **Streaming** (`POST /tts/stream`) | Its response is a custom length-prefixed frame format with heartbeat frames mixed in; n8n has no frame parser and would hand back one opaque buffer. It also caps at 4 concurrent streams per key, and every stream bills at the v4 multiplier whatever engine was sent. See [Streaming](../cloud-api/streaming) if you need that surface. |

### Picking a voice

The Voice field is a resource locator with two modes:

- **From List** — searchable, backed by `GET /voices`. Filtering happens in the
  node, because the API has no search parameter: it folds diacritics (`đ`/`Đ`
  included) and requires every word to match against id, name, description,
  gender, region, engine and kind. Rows read
  `Name — gender · region · engine (id)`, with `· your clone` inside the facet
  group for **any** voice the API returns as `kind: "cloned"` — your own clones
  and admin-published ones alike, so a public clone somebody else created is
  labelled that way too. The list pages 250 at a time.
- **By ID** — for expressions. Copy the id exactly, diacritics included.

Setting **Engine** first scopes the list, and on Speech: Generate the list
re-loads when Engine changes. Do that: a voice renders only on its own engine and
is never substituted — a mismatch is a hard 400. The dropdown is loaded live from
`GET /engines`, so since `v3` was retired on 2026-09-24 it offers `v4` alone; the
only mismatch left is an old `v3` slug pasted By ID.

:::warning A `clone_…` voice does not work on short text
The short-text route the node picks by default, `POST /audio/speech`, rejects
every cloned voice outright — its voice check runs without a user id, so it
cannot tell your clone from anyone's and refuses them all with a 400 reading
*"Cloned voice … can only be used on POST /v1/tts or POST /v1/tts/stream."* This
applies to admin-published clones too.

The long-text route is `POST /tts`, which does accept clones — your own and
published ones. So to use a `clone_…` voice from the node, keep the item **over**
the Synchronous Route Threshold, or set **Options → Synchronous Route Threshold**
to `1` to force every item onto the async route.

Do not follow the node's 400 advice here. It says the usual cause is a voice
belonging to the other engine, which is true of catalogue voices and has nothing
to do with this.
:::

Ids are not descriptive. Many are opaque slugs such as `vieneu-2-000494` whose
voice is named `Minh Đức`, and a few are an unrelated person's name outright.
Never infer gender or identity from an id.

## Speech: Generate

| Parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| Text | string (4 rows) | — | Required. Billed per character with a 50-character minimum — see [Billing](../cloud-api/overview#billing). |
| Engine Name or ID | options | account default | Empty = the account default decides, which also decides the rate. |
| Voice | resource locator | engine default | Scoped by Engine. |
| Put Output File in Field | string | `data` | Required. The binary property the audio lands in. |
| Options | collection | `{}` | Nine options, below. |

### Options

| Option | Name | Default | Notes |
| --- | --- | --- | --- |
| AI Refine | `aiRefine` | `false` | Runs AI normalisation and moderation first. Costs a surcharge on top of the character count and adds latency. No `/v1` route reports the surcharge — the node's help text cites 1.3× at the time of writing. It is also the only thing that can produce a 422. |
| Emotion Name or ID | `emotion` | engine default | Loaded from `GET /emotion-tags` for the selected engine. Engine-specific: v4 has no styles, so the list stays empty there. |
| File Name | `fileName` | derived | Empty derives a name from the format the API actually produced. |
| Format | `format` | `wav` | `wav`, `mp3`, `opus`, `pcm`, `ulaw`. |
| Download Audio | `downloadAudio` | `true` | Long-text route only — see below. |
| Job Timeout (Seconds) | `jobTimeout` | `600` | Minimum 5. How long to poll a long-text job. |
| Poll Interval (Seconds) | `pollInterval` | `2` | Minimum 0.5, and clamped to 500 ms in code regardless of what the workflow JSON says. |
| Speed | `speed` | `1` | 0.5–2.0. |
| Synchronous Route Threshold | `syncMaxChars` | `500` | Minimum 1. Where the route split happens. |

The node sends no sample rate and exposes no option for one.

:::tip You may not need Job Timeout at all
Job Timeout exists because the node polls. If your jobs are long enough that the
timeout is a real risk, register a webhook instead: VieNeu POSTs a signed event
when the job reaches a terminal state, and an n8n **Webhook** trigger node
receives it. `POST /tts` — the route this option governs — is the only route that
produces those events. See [Webhooks](../cloud-api/webhooks).
:::

### Two routes, chosen by text length

| | Short text | Long text |
| --- | --- | --- |
| Condition | length ≤ `syncMaxChars` (default 500) | longer |
| Endpoint | `POST /audio/speech` (blocking) | `POST /tts`, then poll `GET /tts/{jobId}` |
| Body fields | `input`, `response_format`, `speed`, and `voice` / `engine` / `emotion` / `aiRefine` when set | `text`, `speed`, and `voiceId` / `engine` / `emotion` / `aiRefine` when set |
| Formats | all five | WAV only — the route has no format parameter |
| Cloned voices | rejected with 400 | accepted (yours and admin-published) |
| `Download Audio` | ignored; bytes come back inline | applies |
| Extra JSON out | `sampleRate`, `requestId` | `jobId`, `duration`, `audioUrl`, `audioUrlExpiresIn` |

`format` and `mimeType` are set on **both** routes and are listed with the shared
fields [below](#output) — do not treat their presence as a signal of which route
an item took. Read `route` for that.

Note the field names differ between the routes (`input`/`voice` versus
`text`/`voiceId`). The node handles that; it matters only if you copy a body
between the node and an HTTP Request node.

Raising `syncMaxChars` makes the node hold an HTTP connection — and an n8n worker
slot — open for the whole synthesis.

The finished audio on the long-text route is fetched from a presigned S3 URL
**without** authentication. S3 rejects a presigned request that also carries an
`Authorization` header, so that one call deliberately skips the credential.

### Checks that run before anything is billed

These fail locally, with no request sent and no money spent:

- empty text;
- text over 50,000 characters;
- speed outside 0.5–2.0;
- a non-WAV format combined with text over the threshold. The long-text route
  can only return WAV, so this is refused rather than silently downgraded.

### Output

Every item's JSON carries:

| Field | Meaning |
| --- | --- |
| `route` | `sync` or `async` — the only reliable way to tell which route ran |
| `engine`, `voiceId` | what was requested, or `null` for the default |
| `textLength` | raw character count |
| `billedCharacterBasis` | NFC character count with a 50-character floor |
| `billingNote` | states that the basis is before the engine multiplier and the AI-refine surcharge |
| `format` | the format actually produced; always `wav` on the long-text route |
| `mimeType` | read off the response, not the request — see below |

`billedCharacterBasis` is the **basis** of the charge, not a token total. The
backend applies four factors in this order: the character basis (50-character
floor), then the AI-refine surcharge if it is on, then the engine multiplier,
then a **global token multiplier** — an administrator setting, 1 by default, that
no `/v1` route reports. That last factor is why no number the node prints is a
quote. Read the engine multiplier live from Engine: Get Many, and treat any
budget you compute as approximate.

## Where the audio comes out

The audio is attached to the binary property named by **Put Output File in
Field** — `data` unless you change it. The same item's JSON also gains `fileName`
and `fileSize`.

Rename the field when a node upstream already occupies `data`, or when a
downstream node expects a specific name.

The mime type and file name are read off the **response**, never from the
request:

1. the `X-Output-Format` header, if it names a known format;
2. otherwise the `Content-Type`;
3. the file name comes from `Content-Disposition` when present, else
   `speech.<ext>` — `wav`, `mp3`, `ogg` for Opus, and `raw` for the headerless
   `pcm` and `ulaw`.

That indirection is deliberate: the short-text route can fall back to WAV against
an older worker, and these headers are the only signal that it did. Labelling the
binary from the request would hand a downstream node WAV bytes marked `audio/mpeg`.

On the long-text route the default name is `speech-<jobId>.wav`.

**Feeding the next node.** Any node that consumes a file asks for the binary
property by name: give it `data`, or whatever you renamed the field to. The size
and name are also on the item's JSON as `{{ $json.fileSize }}` and
`{{ $json.fileName }}`, so a downstream IF can check them without touching the
bytes. To keep a large WAV out of workflow memory when you only need the link,
switch **Download Audio** off on the long-text route and pass
`{{ $json.audioUrl }}` along instead — it stays valid for a limited time, and the
JSON reports how long as `audioUrlExpiresIn`.

## How failures surface

Every API failure becomes a `NodeApiError` whose message reads:

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

The prose is in `description`. The part to branch on is n8n's machine-readable
`failure.cause`. What each status means on the API side is in
[Errors](../cloud-api/overview#errors); this table is the mapping the node
adds on top:

| Status | `failure.cause` | What happened |
| --- | --- | --- |
| 400 | `configuration-invalid` | Usually a voice belonging to the other engine — but also a `clone_…` voice on the short-text route, non-Vietnamese text, and a `vn_test_` key over its 100-word cap. |
| 401 | `credential-invalid` | The key was rejected. Paste a current one into the credential. |
| 403, message mentions an engine | `configuration-invalid` | The plan does not include that engine. |
| 403, otherwise | `quota-exhausted` | The token grant is exhausted or expired. **No wait hint is set** — it does not clear on its own. |
| 404 | `configuration-invalid` | Nothing under that id. A jobId from another key reads exactly like one that never existed; a withdrawn voice answers 404, not 400. |
| 413 | `configuration-invalid` | Payload too large. |
| 422 | `configuration-invalid` | Moderation refused the text. Only reachable with AI Refine on. Rephrase; retrying unchanged fails again. |
| 429 | see below | Three different limiters. |
| 503 | `temporarily-unavailable` | No worker free for that engine. Transient. |
| other 5xx | `temporarily-unavailable` | Retry shortly. |
| anything else | `node-defect` | Unexpected response. |

VieNeu answers **403** when tokens run out — never 402. Retry logic keyed on 402
will never fire.

DNS, TLS, reset and timeout failures never reach that table; they become a
`temporarily-unavailable` error reading "Could not reach VieNeu while …", whose
description tells you to check that the Base URL includes `/api/v1`.

### Rate-limited versus quota-exhausted

A 429 can come from three places that want opposite responses. The node reads the
response headers to tell them apart — which is why it inspects the status itself
rather than letting n8n throw and lose them.

| Signal on the response | Which limiter | `failure.cause` | Wait hint | What to do |
| --- | --- | --- | --- | --- |
| `Retry-After` present | The app throttler, counted **per API key** | `rate-limited` | `retryAfterMs`, from the header | Wait the stated time and retry. Nothing is wrong with the balance. |
| No `Retry-After`, body matching `token limit reached` / `resets at` / `quota` | The **token quota** — a daily or weekly cap on the grant | `quota-exhausted` | `resetsAtEpochMs`, parsed out of the message text | Do not retry. Wait for the named reset, or top up. |
| No `Retry-After`, no such wording (nginx HTML) | The **nginx edge**, counted per source IP | `rate-limited` | none | Back off hard. On n8n Cloud that IP is shared with unrelated customers. |

Two of the three collapse onto `rate-limited`. Tell them apart by whether
`retryAfterMs` is set: present means the app throttler and a known wait; absent
means the edge and no wait hint at all.

The quota reset timestamp is parsed out of prose because it has to be: the
controller keeps only the message string, so the text is the last surviving trace
of the structured `resetAt` field.

Note that two different statuses map to `quota-exhausted`: the 429 above, which
carries a reset, and the 403 grant exhaustion, which carries none.

### Failures that are not HTTP failures

- **A failed job returns HTTP 200** with `status: "failed"`. Speech: Generate
  raises it as an error; Job: Get returns it as data, so a Wait + IF loop can
  see the terminal state and leave the loop.
- **A poll timeout** raises a 504-shaped error stating that the job is still
  running and has **already been billed**, naming its `jobId`. Collect it later
  with Job: Get. Keep Job Timeout under n8n's own execution timeout, or n8n kills
  the run and the jobId goes with it.
- **An empty audio body**, or a submit that returns no `jobId`, raises a
  502-shaped error.
- **A 200 with an empty voice list and an `error` field** — the catalogue read
  failing — is raised as an error rather than rendered as an empty dropdown.

When the node is set to continue on failure instead of stopping, the failure
becomes item JSON: `error` with the redacted message, plus `jobId` **only when a
job was already submitted**. Nothing else is on that item — in particular, no
binary. A short-text failure, and an async failure that happened at submit, carry
`error` with no `jobId` at all.

## Example workflow: narrate long text, recover the ones that time out

Seven minutes of audio can outlast a poll window, and the job is billed at
submit. This workflow keeps those jobs instead of paying for nothing.

If you can receive an inbound HTTPS request, a
[webhook](../cloud-api/webhooks) and an n8n **Webhook** trigger node avoid the
whole problem — there is no poll window to outlast. Build this only when you
cannot.

1. **Manual Trigger** — "When clicking 'Execute workflow'". Replace it with
   whatever produces your items; each item needs a `text` field.
2. **VieNeu** — Resource *Speech*, Operation *Generate*.
   - **Text**: `={{ $json.text }}`
   - **Engine**: pick one, so the voice list is scoped to it.
   - **Voice**: *From List*, search for the voice you want.
   - **Put Output File in Field**: `data`
   - **Options → Format**: `WAV` (long text returns nothing else).
   - **Options → Job Timeout (Seconds)**: `900`, below your n8n execution
     timeout.
   - Open the node's **Settings** tab and change **On Error** from *Stop
     Workflow* to the option that continues using the node's **regular output**
     — not the one that adds a separate error output connector, which would send
     failed items down a branch the IF in step 3 never sees. Without this, one
     timed-out job aborts the whole run and the jobId is lost.
3. **IF** — "Failed?". Condition: `={{ $json.error }}` *is not empty*. True means
   something went wrong; false means the item has its audio on `data`.
4. **True branch → IF** — "Recoverable?". Condition: `={{ $json.jobId }}` *is not
   empty*. True means the job was billed and is probably still running. False
   means the failure happened before a job existed — a bad key, a wrong voice, a
   429 — so there is nothing to collect: send it to an error output, a Slack
   message, or wherever you want to see it. Do **not** merge it into step 8; it
   has no binary and never will.
5. **Recoverable → Wait** — 60 seconds. Polling costs no tokens and no
   rate-limit budget, but it is not free at the edge.
6. **Wait → VieNeu** — Resource *Job*, Operation *Get*.
   - **Job ID**: `={{ $json.jobId }}`
   - **Download Audio**: on (it is off by default on this operation)
   - **Put Output File in Field**: `data`
7. **IF** — "Terminal?". Condition: `={{ $json.status }}` equals `completed`.
   True → step 8. False → an IF on `={{ $json.status }}` equals `failed`, whose
   true side ends the branch (a No Operation node) and whose false side loops
   **back to the Wait node** in step 5. A job that outlasted a 900-second poll is
   not reliably finished 60 seconds later, and without this loop the item falls
   through with no binary attached.
8. Merge the completed items — those from step 3's false branch and those from
   step 7's true branch — into whatever consumes the file: upload, email,
   storage. Both carry the same binary property name, so downstream nodes do not
   need to know which path an item took.

The same shape works item-by-item over a spreadsheet. Remember that each row is a
separate billed synthesis.

## Don't want to install the node?

Every operation is one HTTPS call, and n8n's built-in **HTTP Request** node makes
all of them. This path works on n8n Cloud, needs no build step, and needs nothing
from VieNeu but a key.

Two ready-made workflows ship with the package, under
`n8n-nodes-vieneu/templates/`:

| File | What it builds |
| --- | --- |
| `vieneu-speech-http-request.json` | Manual trigger → one HTTP Request to `POST /audio/speech` → MP3 on the `data` binary field. |
| `vieneu-long-text-http-request.json` | Submit to `POST /tts`, then Wait → poll → IF until the job is terminal, then download the presigned audio. |

Import either with *Workflows → Import from File*. Both attach the key through a
Bearer Auth credential, never a header typed into a node parameter. Note that
neither has been executed against a live n8n — they are validated structurally.

If you do not have the package, the recipes below build the same two workflows by
hand. For the underlying request contract, see the
[Cloud API overview](../cloud-api/overview).

Find a voice id first. Both calls below need no key. Since 2026-09-24 the cloud
API has one engine, V4, whose ids are display names (`Ngọc Lan`); the `vieneu-…`
slugs of the retired V3 catalogue no longer resolve and 400 on `voiceId`, so
refresh any id you saved before then.

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

Check that your key works, and hear the result, before wiring anything:

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

### Short text: one HTTP Request node

| Setting | Value |
| --- | --- |
| Method | `POST` |
| URL | `https://api.vieneu.io/api/v1/audio/speech` |
| Authentication | Generic Credential Type → **Bearer Auth**, holding your `vn_sk_…` key |
| Send Body | on, JSON |
| Response → Format | **File**, output property `data` |

Body, as an expression so the text can come from the incoming item:

```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
}) }}
```

Use a **Bearer Auth credential**, not an `Authorization` header typed into the
node. Credentials are referenced by id and are not part of the exported workflow
JSON — that is the difference between a workflow you can paste into an issue and
one that leaks your key when you do.

`response_format` accepts `mp3`, `wav`, `opus`, `pcm`, `ulaw` here. This route
blocks for the whole synthesis, so keep it for short text. A `clone_…` voice is
rejected here with a 400, same as through the node.

### Long text: submit, poll, download

Past roughly 500 characters, queue the job instead of holding a connection open.

Before you build the loop: if your n8n can receive an inbound HTTPS request, a
[webhook](../cloud-api/webhooks) replaces steps 2 to 5 with a single **Webhook**
trigger node. VieNeu POSTs a signed event the moment the job reaches a terminal
state, and `POST /tts` is the only route that produces those events. Poll only
when an inbound endpoint is not available to you.

The polling version, matching `vieneu-long-text-http-request.json`, is eight
nodes: a Manual Trigger and the seven below.

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

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

   Note the field names on this route: `text` and `voiceId`, not `input` and
   `voice`. It takes no format parameter and always returns WAV. It responds with
   a `jobId`, and the text is billed **here**, before the job is queued.
2. **Wait** — 3 seconds.
3. **HTTP Request — "Check job status"**:
   `GET https://api.vieneu.io/api/v1/tts/{{ $json.jobId }}` (as an expression),
   same Bearer Auth credential.
4. **IF — "Completed?"**: `={{ $json.status }}` equals `completed`. True →
   download. False → step 5.
5. **IF — "Failed?"**: `={{ $json.status }}` equals `failed`. True → step 6.
   False → back to the **Wait** node. A failed job answers HTTP **200**, so
   branch on the body, never the status code.
6. **No Operation — "Job failed"**: ends the failed branch.
7. **HTTP Request — "Download audio"**: `GET {{ $json.audioUrl }}`, Response
   Format **File**, output property `data`, and **no credential and no
   authentication**. The URL is a presigned S3 link carrying its own signature —
   S3 rejects the request outright if an `Authorization` header rides along.

Errors on this path arrive raw, without the node's classification. The rules
still hold: a 429 with `Retry-After` is the per-key throttler; a 429 whose
message mentions a token limit or a reset time is the quota; a 429 with neither
came from the edge and is counted per source IP. Running out of tokens entirely
is a 403.
