# Canto-Gen (港嘢) API Documentation

Cantonese-optimized text-to-speech: nine tones preserved, particles pronounced right. Preset gallery voices are all Cantonese-native.

- Base URL: `https://canto-gen.com`
- Auth: `Authorization: Bearer $CANTOGEN_API_KEY`
- Interactive reference: /v1/docs/reference · OpenAPI: /v1/openapi.json · llms.txt: /llms.txt

Two ways to generate speech — **sync** for short text, **async** for everything else (both accept the same request fields):

## Get a key

1. Sign in and open Account → API Keys (`/account/api-keys`) to create a key.
2. The raw key is shown **exactly once** — copy it immediately. Only a SHA-256 hash is stored server-side; a lost key must be revoked and replaced.
3. Export it for the examples below:

```bash
export CANTOGEN_API_KEY="sk_live_..."
```

| Prefix | Purpose |
|---|---|
| `sk_live_…` | The only key format. Free-plan keys are subject to a daily generation limit; paid-plan usage counts against the key's monthly character cap. |

| Scope | Unlocks |
|---|---|
| `tts:generate` | Default. Unlocks voice & design generation plus all read endpoints (voice catalog, job polling). |
| `tts:clone` | Required for clone mode (voice cloning from a reference sample). |
| `tts:design` | Fine-grained scope for design mode (gender + age synthesized voices) — already covered by tts:generate; either one suffices. |
| `voices:manage` | Required for personal voice management (MCP list / create / delete my voices). |

Keys also carry a per-minute rate limit (default 60) and an optional monthly character cap.

**Free plan**: Free users can create an API key too (always the `sk_live_` format), capped at 50 generation requests per day (polling and downloads don't count). Upgrading to the Lite plan unlocks full API access on the same key instantly — no need to re-create it.

## Pricing

The API is available on **any paid plan (Lite / Starter / Pro / Studio)**, at full speed with no daily cap. API usage draws from the same account character pool as the web playground, resetting on your billing cycle (subscription anniversary) — 1 character = 1 character, no surcharge. When the pool runs out, buy credit packs. The `X-Credits-Charged` response header shows the exact characters billed for each request. See /pricing for plan details.

Free users can create an API key too, capped at 50 generation requests per day (polling and downloads don't count). Upgrading to the Lite plan unlocks full access.

## Glossary

- **Daily generation cap** — Free-plan keys: 50 generation requests per day (polling and downloads don't count). Paid plans have no daily cap.
- **Per-key monthly character cap** — A key's month-to-date (UTC) billed-character cap; exceeding it returns 403 monthly_cap_reached. Unset by default.
- **Account character pool** — Account-level character balance shared by the web playground and the API, resetting each billing cycle; requests return 402 character_limit_reached when exhausted.
- **Credit pack** — One-time top-up for the account character pool; never expires.
- **Pending-job cap** — Concurrent pending jobs per account, tiered by plan (3 / 4 / 5 / 10 / 20 / 20); exceeding returns 429 in_flight_limit_reached.

## Quickstart

List voices, then submit a speech job with any `slug` from the catalog:

```bash
curl https://canto-gen.com/v1/voices \
  -H "Authorization: Bearer $CANTOGEN_API_KEY"
```

```json
{
  "voices": [
    {
      "slug": "sum",
      "name": "心妍",
      "gender": "female",
      "age": "adult",
      "styles": [],
      "preview_url": "https://canto-gen.com/api/voice-clip?slug=sum"
    }
  ]
}
```

> The catalog is live — always pick a `slug` from your own `GET /v1/voices` response. `"sum"` (心妍) here is the documented example.

Submit a speech job (`model` is optional — `"cantogen-tts"` is the only value):

```bash
# Async by design — submit returns 202 with a job_id, then poll.
export CANTOGEN_API_KEY="sk_live_…"

curl -X POST https://canto-gen.com/v1/audio/jobs \
  -H "Authorization: Bearer $CANTOGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "cantogen-tts",
    "voice": "sum",
    "text": "你好，歡迎使用港嘢！",
    "response_format": "mp3"
  }'
```

Response — **`202 Accepted`**:

```http
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "job_id": "b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "status": "queued",
  "poll_url": "/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "srt_requested": false
}
```

Every successful async submit returns `202` with `job_id` / `status` / `poll_url` / `srt_requested`.

Bulk: send `{"items": [ …up to 25 request bodies… ]}` — validation is all-or-nothing (`400 invalid_items` if any item is invalid); after enqueue, per-item outcomes are reported in the `202 {"jobs":[…]}` body. Over 25 items returns `400 too_many_items`. Bulk generation requires the Starter/Pro/Studio plan. Pending-job cap is tiered by plan (3 / 4 / 5 / 10 / 20 / 20 on free/lite/starter/pro/studio/enterprise); exceeding it returns `429 in_flight_limit_reached` — wait for current jobs to finish before submitting more.

### Sync shortcut — POST /v1/audio/speech

POST /v1/audio/speech is the synchronous shortcut: it returns the raw audio bytes with 200 OK (plus X-Audio-Duration / X-Credits-Charged headers), typically in 5–15 s. Limits: text up to 300 billable characters, voice (preset) or design mode only — clone returns 400 clone_not_supported_sync and srt returns 400 srt_not_supported_sync (use the async endpoint for both).

```bash
curl -X POST https://canto-gen.com/v1/audio/speech \
  -H "Authorization: Bearer $CANTOGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "cantogen-tts",
    "voice": "sum",
    "text": "你好，歡迎使用港嘢！",
    "response_format": "mp3"
  }' --output speech.mp3
```

### Cold pool — honest 503

While the sync queue is busy (any generation in flight), the endpoint fails closed with the 503 above — nothing is billed and there is no silent async fallback; use POST /v1/audio/jobs instead. If a request was accepted but the wait exceeds ~45 s, it returns 202 {job_id, poll_url} — the work keeps running and is never double-billed; just switch to polling.

```http
HTTP/1.1 503 Service Unavailable

{ "error": "pool_cold", "hint": "use POST /v1/audio/jobs (async)" }
```

## Voice modes

The three voice modes are mutually exclusive: send exactly one of voice, design, or clone per request — anything else returns 400 voice_mode_conflict. Designed and cloned voices are per-request: they are never saved as reusable voice assets.

### voice — Gallery voice

Pick a ready-made voice from the Cantonese-optimized gallery by slug.

- Browse GET /v1/voices — every preset is Cantonese-native, with preview clips.
- Pass the slug as voice. sum (心妍) is the documented example — always pick a slug from your own response. Slugs are stable ASCII identifiers (industry standard); Chinese names are for display.

```bash
# Async by design — submit returns 202 with a job_id, then poll.
export CANTOGEN_API_KEY="sk_live_…"

curl -X POST https://canto-gen.com/v1/audio/jobs \
  -H "Authorization: Bearer $CANTOGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "cantogen-tts",
    "voice": "sum",
    "text": "你好，歡迎使用港嘢！",
    "response_format": "mp3"
  }'
```

### design — Designed voice

Describe a synthetic voice in plain text — gender + age. No audio files, nothing to upload.

- gender: male | female · age: child | teen | young | adult | midage | elder — both required.
- Great for product personas; combine freely with emotion and srt.

```bash
curl -X POST https://canto-gen.com/v1/audio/jobs \
  -H "Authorization: Bearer $CANTOGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "cantogen-tts",
    "design": { "gender": "female", "age": "young" },
    "text": "今日天氣幾好，一齊去行山啦。",
    "style": "brisk",
    "emotion": "happy",
    "srt": true
  }'
```

### clone — Cloned voice

Upload a reference sample — the platform transcribes it internally (ASR) and applies the transcript automatically. Zero extra calls on your side.

- reference_audio: data URL or raw base64 — audio/mpeg, wav, webm, mp4/m4a, ogg. One request is capped at 4.4 MB (≈3 MB of audio once base64-encoded).
- Samples must be at least 8 seconds (shorter is rejected); processing auto-trims to ~15–20 s on sentence boundaries (30 s cap) — a clean sample works best.
- Only reference_audio is needed; the legacy prompt_text field is retired (any value is ignored).
- Requires an API key with the tts:clone scope.

```bash
B64=$(base64 -w0 sample.wav)

curl -X POST https://canto-gen.com/v1/audio/jobs \
  -H "Authorization: Bearer $CANTOGEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"model\": \"cantogen-tts\",
    \"clone\": {
      \"reference_audio\": \"data:audio/wav;base64,$B64\"
    },
    \"text\": \"用我把聲講呢句嘢。\"
  }"
```

Other generation parameters:

- `style` — Delivery style — English slugs recommended. Rhythm: steady（平穩） / composed（從容） / rapid（急促） / slow（慢板） / drawl（拖長） / brisk（明快） / dynamic（起伏） / staccato（頓挫）. Timbre: mellow（圓潤） / cute（奶音） / sharp（尖細） / lazy（懶音） / breathy（氣聲） / hoarse（沙啞） / deep（洪厚） / crisp（清脆） / feeble（無力） / trembling（顫音）. Scene and character slugs are accepted too (corporate / promo / … / storytelling / sports — full list in the OpenAPI schema). The original Chinese preset values (明快 / 圓潤 etc.) remain accepted as aliases — both are normalized internally to the same canonical value; any other free text also passes through.
- `emotion` — One of 9 supported values (see chips below). Note: the API enum uses English values (happy / excited etc.), passed to the engine as-is. This is a distinct system from the Chinese emotion presets in the Playground UI (雀躍 / 冷靜 etc.) — those are Playground-only; the API emotion parameter does not accept Chinese values.
- `instruction` — Free-text delivery direction, up to 500 characters. Priority: style → emotion → instruction — instruction has the final say.
- `pitch` — Deprecated (2026-09-20): no-op under raw audio delivery (accepted but ignored). Historical range −12…+12 semitones, default 0.
- `volume` — Deprecated (2026-09-20): no-op under raw audio delivery (accepted but ignored). Historical range 0–10, default 1.
- `response_format` — mp3 (default) / wav.
- `srt` — true → the completed job carries an srt field (billed at a higher character rate). Available on all plans.
- `emotion` values: `happy` / `sad` / `angry` / `fearful` / `disgusted` / `surprised` / `neutral` / `excited` / `gentle`

## Inline tone tags

`text` supports inline tone tags: insert `[label]` inside the script and the line is delivered with that tone/action. The closed set is 15 tags:

| label | token | position |
|---|---|---|
| `笑聲` | `laughing` | head or tail |
| `嘆氣` | `sigh` | head or tail |
| `深呼吸` | `breath` | head (before the sentence) |
| `遲疑` | `Uhm` | head (before the sentence) |
| `噓` | `Shh` | head (before the sentence) |
| `唓` | `Dissatisfaction-hnn` | head (before the sentence) |
| `啊?` | `Question-ah` | tail (sentence-final) |
| `誒?` | `Question-ei` | tail (sentence-final) |
| `嗯?` | `Question-en` | tail (sentence-final) |
| `哦?` | `Question-oh` | tail (sentence-final) |
| `嘩!` | `Surprise-wa` | head (before the sentence) |
| `唷!` | `Surprise-yo` | tail (sentence-final) |
| `呀!` | `Surprise-ah` | tail (sentence-final) |
| `哦!` | `Surprise-oh` | tail (sentence-final) |
| `嗯哼` | `Confirmation-en` | tail (sentence-final) |

Position rules: `head` tags open the sentence (pre-speech actions / interjections); `tail` tags are sentence-final particles. Question tags (`啊?` `嗯?` `哦?` `誒?`) only fit lines ending in `？`/`?`. Unknown bracket tokens pass through untouched (nothing is ever deleted). Tag characters bill as normal text.

Example: `"text": "[笑聲]今日天氣好好[唷!]"` — a laugh before the sentence and a sentence-final particle after it.

## Polling & SRT

Status lifecycle: `queued` → `processing` → `completed` | `failed` | `cancelled`; a cancelled in-flight job briefly shows the transient `cancelling` (see Cancel a job below). Poll `GET /v1/audio/jobs/{id}` every 2–5 s (with backoff):

```bash
curl https://canto-gen.com/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b \
  -H "Authorization: Bearer $CANTOGEN_API_KEY"
```

When `status` is `"completed"`, the response carries a pre-signed `audio_url`:

```json
{
  "job_id": "b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "status": "completed",
  "poll_url": "/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "srt_requested": true,
  "srt": "1\n00:00:00,000 --> 00:00:02,400\n你好，歡迎使用港嘢！",
  "audio_url": "https://canto-gen.com/storage/v1/object/sign/audio_history/..."
}
```

Download it with a plain GET (no auth header needed — the URL is pre-signed):

```bash
curl -sL -o speech.mp3 "$AUDIO_URL"
```

> audio_url is a pre-signed URL that expires: valid for 1 hour when served from the materialized audio library, and 7 days when served straight from the generation worker. Download promptly and re-poll the job for a fresh URL. audio_url is absent while the job is queued/processing and on failed/cancelled jobs.

A malformed job id returns `400 {"error":"invalid_job_id"}`; an unknown id returns `404 {"error":"job_not_found"}`.

**Subtitles (SRT):** send `"srt": true` and the completed job's poll response carries an `srt` field with the subtitle content (available on all plans). Billing nuance: account credit for the job includes an SRT surcharge (billedCharacters exceeds base characters when srt: true), while the per-key monthly cap ledger counts the submitted text length flat. Subtitles are available on all plans.

**Rate limits & caps:** Monthly character cap — the key's month-to-date (UTC) billed characters must stay under its cap (null = unlimited). Exceeding it returns 403 monthly_cap_reached with a used figure until the month resets. X-RateLimit-* headers are attached to the 202 and 429 responses of POST /v1/audio/jobs and the 429 responses of POST /v1/audio/speech (200 responses carry the usage headers — X-Credits-Charged etc.); the read endpoints (GET /v1/audio/jobs/{id}, GET /v1/voices) are not rate-limited. Both meters are designed to fail open — a metering outage never locks you out.

### Cancel a job — DELETE /v1/audio/jobs/{id}

Jobs you no longer need can be cancelled — send `DELETE /v1/audio/jobs/{id}` for the same `job_id`:

```bash
curl -X DELETE https://canto-gen.com/v1/audio/jobs/<job_id> \
  -H "Authorization: Bearer \$CANTOGEN_API_KEY"
```

Four outcomes:

- Not yet started (`queued`) → **`200`** `{ "ok": true, "status": "cancelled", "refunded": 1234 }` — cancelled immediately and fully refunded (`refunded` is the actual number of characters refunded).
- In flight (`processing`) → **`202`** `{ "ok": true, "status": "cancelling" }` — cancellation accepted and the job winds down; polling shows `status: "cancelling"` before it settles as `"cancelled"`.
- Already settled (`completed` / `failed` / `cancelled`) → **`409`** `{ "error": "already_settled" }` — the job can no longer be cancelled and no refund applies.
- Unknown id (or owned by another key) → **`404`** `{ "error": "job_not_found" }`.

## Transcription

Transcription — upload an audio file and receive the transcript synchronously in the same request. Canonical path: `POST /v1/asr/transcribe`; OpenAI-compatible clients may keep using Sent as multipart/form-data. Fields: `file` (required), `model` (required), `language` (optional, ISO-639-1), `response_format` (`json` default / `text`). `srt` and `verbose_json` need segment-level timestamps and are not yet supported (400 unsupported_response_format). Transcription is currently Cantonese-optimized.

**Billing:** Billed by audio duration from the prepaid HK$ balance — HK$1.40/hour for file transcription, HK$2.80/hour for realtime dictation (2×), charged per second with no rounding up. The estimated amount is held before processing and settled on the actual duration when done; failures, timeouts and outages are refunded in full (success-only billing). An insufficient balance returns 402 insufficient_balance — top up at /account/billing and retry. GET /v1/balance returns your balance and the current rates.

Limits:

- `file size` — Max 20 MB per file (over it returns 413 payload_too_large).
- `audio duration` — Audio is capped at ≈9 minutes per request on the synchronous endpoint (over it returns 503 engine_unavailable with a full refund); the 4-hour cap is planned but not live yet.
- `daily cap` — 24 hours of billable audio per day (Hong Kong day boundary; over it returns 429 daily_limit_exceeded).

Example:

```bash
curl -X POST https://canto-gen.com/v1/asr/transcribe \
  -H "Authorization: Bearer \$CANTOGEN_API_KEY" \
  -F file=@sample.wav \
  -F model=cantogen-asr \
  -F response_format=json
```

Response — **`200`**:

```json
{
  "id": "b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "object": "transcription",
  "text": "今日天氣幾好，一齊去行山啦。",
  "duration_seconds": 1830,
  "charged_hkd": 0.7117
}
```

> Transition note: delivery is synchronous — short and medium files finish in-request; audio whose processing exceeds the synchronous wait window (≈9 minutes of audio) is refunded in full and returns 503 engine_unavailable (the worker still finishes; there is no retrieval surface yet). The 4-hour claim returns once the async retrieval API ships.

### Balance — GET /v1/balance

Get the prepaid balance (HKD), the file-transcription / realtime-dictation rates and the auto top-up settings. Test keys report the test-environment balance (isolated from production). Top up at /account/billing when the balance runs low (auto top-up can charge a set amount whenever the balance falls below a threshold).

```bash
curl https://canto-gen.com/v1/balance \
  -H "Authorization: Bearer \$CANTOGEN_API_KEY"
```

```json
{
  "object": "balance",
  "currency": "HKD",
  "balance_hkd": 50,
  "asr": {
    "batch_rate_hkd_per_hour": 1.4,
    "realtime_rate_hkd_per_hour": 2.8,
    "billed_unit": "audio_second"
  },
  "auto_topup": { "enabled": true, "threshold_hkd": 20, "amount_hkd": 100 }
}
```

## Realtime WebSocket

Alongside REST, Canto-Gen exposes a WebSocket realtime endpoint for text-in → streamed PCM audio-out: keep one long-lived connection and stream tokens in while audio comes out — built for bring-your-own-LLM voice apps. The protocol is based on **OpenAI Realtime**; every Canto-Gen extension event and field is marked as such.

`wss://api.canto-gen.com/v1/audio/realtime`

### Auth

Authenticate with the same `sk_` API key as REST; the required scope is `tts:generate`. Prefer the `Authorization: Bearer sk_…` header. The browser WebSocket API cannot set custom headers, so browsers may fall back to the query parameter `?api_key=sk_…` (alias `?token=`). Query parameters are written to URLs, server logs and browser history, so keep them **browser-only** — code running on a server must always use the header.

### Events — client → server

| Event | Origin | Purpose |
|---|---|---|
| `session.update` | OpenAI-native | Open/update the session: voice, language, output_audio_format, max_session_chars, tts.segmentation. |
| `conversation.item.create` | OpenAI-native | Send one explicit utterance (input_text); triggers synthesis. |
| `input_text.append` | Canto-Gen extension | Send raw token deltas — stream as you generate. |
| `input_text.commit` | Canto-Gen extension | Force a segment boundary (flush now). |
| `response.create` | OpenAI-native | Trigger synthesis. |
| `response.cancel` | OpenAI-native | Barge-in: stop the current utterance and refund it. |
| `session.close` | Canto-Gen extension | Close the session and settle. |

### Events — server → client

| Event | Origin | Purpose |
|---|---|---|
| `session.created` | OpenAI-native | Session opened; carries the server-minted session id. |
| `session.updated` | OpenAI-native | Config ack. |
| `response.created` | OpenAI-native | An utterance begins. |
| `response.output_audio.delta` | OpenAI-native | A base64 PCM16 @ 48 kHz audio chunk. |
| `response.output_audio.done` | OpenAI-native | Utterance audio complete. |
| `response.done` | OpenAI-native | Utterance done; usage carries input_chars / billed_chars / remaining (extension fields). |
| `error` | OpenAI-native | {type, code, message, http_status}. |

`tts.segmentation` (extension) controls utterance segmentation: `{"mode":"server"}` (default) lets the server split on punctuation and synthesize utterance by utterance — the client just keeps appending tokens; `{"mode":"client"}` lets the client mark boundaries itself via `conversation.item.create` / `input_text.commit`. Optional `min_chars` / `max_chars` / `punctuation`.

### Limits & timeouts

- `idle_timeout` — Idle 120 s (no app-level activity) → the server closes the connection and settles.
- `max_session_duration` — Free plan: a single session lasts at most 5 minutes; paid plans have no duration cap. It is closed at the cap.
- `max_sessions_per_user` — Concurrent sessions per user are plan-tiered: free 1 / lite 2 / starter 3 / pro 4 / studio 5 / enterprise 5; unknown plans are treated as free.
- `max_sessions_global` — At most 8 concurrent sessions platform-wide.
- `max_session_chars` — Billable-character cap per session (default 2000, adjustable via session.update); exceeding it returns session_char_limit_exceeded.
- `max_in_flight` — At most 3 un-settled utterances per session.
- `raw_ingress` — Raw input cap of 20,000 characters per session and 5,000 per minute (punctuation and whitespace included); exceeding it returns raw_limit_exceeded, fail-closed.
- `lease` — Each utterance carries a 60-second settlement lease; if it is not settled in time it is refunded automatically.
- `rate_limit / monthly_cap` — The same per-key per-minute rate limit and monthly character cap apply; usage is recorded one row per utterance.
- `daily_generation_limit` — Free plan: 50 generations per day (shared with the REST counter; polling and downloads don't count); exceeding it returns sandbox_daily_limit (429). Paid plans have no daily cap.
- `frame_size` — Single WS message cap: 64 KiB.
- `heartbeat` — The server pings periodically; pings do not count as app activity.

### Node example client

```js
// npm i ws   — one long-lived connection: stream tokens in, stream PCM out.
import WebSocket from "ws";

const ws = new WebSocket("wss://api.canto-gen.com/v1/audio/realtime", {
  headers: { Authorization: `Bearer ${process.env.CANTOGEN_API_KEY}` },
});

ws.on("open", () => {
  ws.send(JSON.stringify({
    type: "session.update",
    session: { voice: "sum", language: "yue", output_audio_format: "pcm16", max_session_chars: 500 },
  }));
  // One explicit utterance — or keep sending input_text.append deltas.
  ws.send(JSON.stringify({
    type: "conversation.item.create",
    item: { type: "message", content: [{ type: "input_text", text: "你好，歡迎使用港嘢！" }] },
  }));
});

const chunks = [];
ws.on("message", (raw) => {
  const ev = JSON.parse(raw.toString());
  if (ev.type === "session.created") console.log("session", ev.session.id);
  if (ev.type === "response.output_audio.delta") chunks.push(Buffer.from(ev.delta, "base64"));
  if (ev.type === "response.done") {
    // PCM16 @ 48 kHz — feed an AudioWorklet or write the chunks to disk.
    console.log("billed", ev.response.usage.billed_chars, "remaining", ev.response.usage.remaining);
    ws.close();
  }
  if (ev.type === "error") console.error(ev.error.code, ev.error.message);
});
```

### Error codes

| code | HTTP | Cause |
|---|---|---|
| `invalid_api_key` | 401 | Key missing, invalid, expired, or wrong scope. |
| `rate_limit_exceeded` | 429 | Per-key per-minute limit hit; carries retry_after. |
| `sandbox_daily_limit` | 429 | Free plan's 50 generations per day are used up (shared counter with REST); try again tomorrow or upgrade. |
| `monthly_cap_reached` | 429 | The key's monthly character cap is reached. |
| `character_limit_reached` | 402 | Account character pool exhausted — the utterance is not synthesized. |
| `concurrency_limit_reached` | 429 | Concurrent session or un-settled utterance cap reached. |
| `session_char_limit_exceeded` | 403 | This session's character cap is reached. |
| `raw_limit_exceeded` | 413 | Raw input volume (including punctuation/whitespace) exceeds the cap. |
| `text_too_long` | 400 | A single utterance exceeds the length limit. |
| `invalid_session` | 400 | The session is closed or otherwise invalid. |
| `engine_unavailable` | 503 | The realtime engine is temporarily unavailable; retry. |

## Errors

Errors use a stable machine-readable `error` code plus an optional human-readable `message`, e.g. `{"error": "text_too_long", "message": "…"}`.

| HTTP | error | Cause / fix |
|---|---|---|
| 400 | `invalid_model` | model must be "cantogen-tts" (or omitted). Omit model or set "cantogen-tts". |
| 400 | `voice_mode_conflict` | More than one of voice / design / clone was sent — the three modes are mutually exclusive. Send exactly one voice mode per request. |
| 400 | `text_required` | text is missing or empty. Include a non-empty text (1–500 characters). |
| 400 | `text_too_long` | text exceeds 500 characters. Split the text — over-limit requests are rejected, not truncated. |
| 400 | `unsupported_format` | response_format must be mp3 or wav. Use mp3 or wav. |
| 400 | `invalid_pitch` | pitch must be between −12 and +12 semitones. Adjust pitch to the valid range. |
| 400 | `invalid_volume` | volume must be between 0 and 10. Adjust volume to the valid range. |
| 400 | `invalid_emotion` | emotion is not one of the 9 supported values. Use one of the 9 supported emotions. |
| 400 | `invalid_srt` | srt must be a boolean. Use true / false. |
| 400 | `reference_audio_required` | clone.reference_audio is missing. Attach a base64 reference sample. |
| 400 | `invalid_reference_audio` | clone.reference_audio is not valid base64 audio. Send a data URL (data:audio/…;base64,…) or raw base64. |
| 400 | `invalid_reference_audio_size` | clone.reference_audio exceeds ~3 MB of audio (the JSON API's per-request cap). Trim the sample (8 s or more is enough) and retry. |
| 400 | `spoken_text_required` | text has no speakable content — zero billable characters (only punctuation / whitespace / emoji / tone tags). Include text with readable words. |
| 400 | `design_gender_and_age_required` | Both design.gender and design.age are required. Provide both fields. |
| 400 | `invalid_job_id` | The poll or cancel id is not a UUID. Check the job_id. |
| 400 | `invalid_request` | The body is not valid JSON, or a non-JSON Content-Type was sent (/v1 accepts application/json only); for transcription, multipart is missing `file`/`model`, the file is empty, or the audio type is unsupported. Send Content-Type: application/json with a valid JSON body. |
| 413 | `payload_too_large` | The request exceeds its size cap — 4.4 MB for the JSON API (usually an over-long clone reference sample); 25 MB for transcription audio files. Trim the reference sample (≤3 MB of audio; 8 s or more is enough); for transcription, use a shorter audio file. |
| 401 | `invalid_or_expired_token` | API key missing, invalid, expired, or revoked. Create a new key and check the Bearer header. |
| 402 | `character_limit_reached` | The account character pool cannot cover this generation (the body includes remaining). Buy a credit pack or wait for the monthly reset. |
| 403 | `insufficient_scope` | The key lacks the required scope (cloning needs tts:clone); the body includes "scope". Create a key with the required scope. |
| 403 | `monthly_cap_reached` | The key's monthly character cap is reached; the body includes "used". Wait for the UTC month to reset or raise the cap. |
| 404 | `job_not_found` | No job with this id (or it belongs to another key). Check the job_id and the key used to submit. |
| 409 | `already_settled` | The job already settled (completed/failed/cancelled) and can no longer be cancelled; no refund applies. Poll GET /v1/audio/jobs/{id} for the final state instead. |
| 409 | `duplicate_request` | The same Idempotency-Key (alias x-idempotency-key) was already submitted (retry safety: nothing was re-billed). Poll the original job_id, or submit with a new key. |
| 404 | `not_found` | No such endpoint (typo in the path). Check the paths in /v1/openapi.json. |
| 403 | `batch_requires_pro` | Bulk generation (items) requires the Starter/Pro/Studio plan. Concurrent pending jobs are tiered by plan (3 / 4 / 5 / 10 / 20 / 20 pending on free/lite/starter/pro/studio/enterprise); exceeding the cap returns 429 in_flight_limit_reached. Wait for current jobs to finish, or upgrade to Starter/Pro/Studio for bulk generation and a higher concurrency cap. |
| 429 | `capacity_exceeded` | The public API generation pool is at its global concurrency cap. Wait Retry-After seconds, or use the async `POST /v1/audio/jobs` path. |
| 429 | `rate_limit_exceeded` | Per-key per-minute limit hit. Wait Retry-After seconds (also mirrored in the body as retry_after), then retry. |
| 429 | `sandbox_daily_limit` | Free plan: the daily limit of 50 generation requests has been reached; polling and downloads are unaffected. Try again tomorrow, or upgrade to the Lite plan for full API access. |
| 429 | `api_busy` | The API generation queue is at its global capacity cap (rejected before reserve — nothing billed). Wait a few seconds and retry. |
| 502 | `audio_delivery_failed` | Generation completed but the audio could not be fetched. Poll GET /v1/audio/jobs/{id} and download the audio_url instead. |
| 503 | `sync_generation_failed` | The job failed or was cancelled during the sync wait (already refunded automatically). Retry via POST /v1/audio/jobs (async). |
| 503 | `reference_upload_failed` | The clone reference audio could not be stored temporarily. Simply retry. |
| 503 | `voice_gallery_unavailable / jobs_unavailable` | Transient backend issue. Retry with backoff. |
| 400 | `unsupported_response_format` | Transcription response_format currently supports json / text only — srt / verbose_json need segment-level timestamps and are not yet available. Use json or text. |
| 402 | `insufficient_balance` | The prepaid balance cannot cover the hold for this transcription (nothing processed, nothing billed). Top up at /account/billing and retry (failed requests are never billed). |
| 429 | `daily_limit_exceeded` | The daily billable-audio cap (24 hours/day, Hong Kong day boundary) is reached. Retry tomorrow (Hong Kong time). |
| 503 | `engine_unavailable` | The transcription service is temporarily unavailable (duration-probe, billing or pipeline failure; audio beyond the current synchronous delivery window also lands here). Any hold is refunded in full. Retry later; nothing is billed. |

## Infrastructure

API speech workloads run on a dedicated GPU pool — fully isolated from the interactive playground, so your production traffic and someone's browser session never compete for the same capacity.

## Reference & downloads

### Endpoints

- `POST /v1/audio/speech` — Short-text synchronous generation — 200 audio bytes (voice/design, ≤300 chars).
- `POST /v1/audio/jobs` — Submit a speech job (single or bulk ≤25; bulk requires Starter or above). Returns 202 with job_id(s) and poll_url.
- `GET /v1/audio/jobs/{id}` — Poll a job; when completed the response carries a pre-signed audio_url.
- `GET /v1/voices` — List the Cantonese-optimized voice catalog, with preview clips. This endpoint allows anonymous access.
- `POST /v1/asr/transcribe` — Transcribe audio to text synchronously; billed by audio duration (HK$1.40/hour), success-only.
- `GET /v1/balance` — Get the prepaid balance (HKD), transcription/dictation rates and auto top-up settings.

- Interactive reference (Scalar): https://canto-gen.com/v1/docs/reference
- OpenAPI 3.1 spec: https://canto-gen.com/v1/openapi.json — generate a typed client in your language
- This page as clean Markdown: https://canto-gen.com/v1/docs/en.md
- Agent index: https://canto-gen.com/llms.txt
- MCP for AI agents: local stdio server (npm package `cantogen-mcp`, run `npx -y cantogen-mcp`; setup prompt in /llms.txt)

---

港嘢 Canto-Gen — Built in Hong Kong.
