{"openapi":"3.1.0","info":{"title":"Canto-Gen API","version":"1.0.0","description":"The Canto-Gen public REST API for text-to-speech and audio transcription.\n\nCanto-Gen (港嘢) turns Cantonese text into natural, native-sounding speech.\nPreset voices are **Cantonese-optimized** (廣東話為本).\n\n## Getting started\n\n1. Create an API key in the playground (「API 金鑰」).\n2. List available voices with `GET /v1/voices`.\n\n**Sync generation — one call returns the audio directly (short text, ≤300 billable chars):**\n\n```bash\ncurl -X POST https://canto-gen.com/v1/audio/speech \\\n  -H \"Authorization: Bearer sk_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"model\": \"cantogen-tts\",\n    \"voice\": \"sum\",\n    \"text\": \"The weather is lovely today, let us go for a hike.\",\n    \"response_format\": \"mp3\"\n  }'\n```\n\n**Async generation — submit a job and poll (any size, bulk ≤25):**\n\n```bash\n# 1) Submit a job — returns 202 Accepted with a job_id\ncurl -X POST https://canto-gen.com/v1/audio/jobs \\\n  -H \"Authorization: Bearer sk_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"model\":\"cantogen-tts\",\"voice\":\"sum\",\"text\":\"The weather is lovely today, let us go for a hike.\"}'\n\n# 2) Poll every 2–5 s until status becomes completed\ncurl -H \"Authorization: Bearer sk_live_…\" \\\n  https://canto-gen.com/v1/audio/jobs/{job_id}\n\n# 3) Download the audio from the response's audio_url\ncurl -o speech.mp3 \"$AUDIO_URL\"\n```\n\nFor the full step-by-step guide (keys, pricing, bulk, SRT subtitles), see the guide: **[/v1/docs/en](/v1/docs/en)**.\n\nAll endpoints require an API key passed as a Bearer token, except `GET /v1/voices`\n(the public voice catalog, anonymous access allowed):\n\n```\nAuthorization: Bearer sk_live_…\n```\n\nCreate and manage keys in the playground under 「API 金鑰」 (API Keys).\nAll keys share the `sk_live_…` prefix; free-plan keys are subject to a daily\ngeneration limit, while paid-plan usage counts against the key's monthly credit cap.\nTwo complementary patterns:\n\n1. **Sync (short text)** — `POST /v1/audio/speech` returns **200 OK** with the\n   audio bytes directly in the response body while the sync queue is fully idle. Limited to 300 billable credits,\n   `voice`/`design` modes only (no `clone`, no `srt`). While the sync queue is busy,\n   requests honestly fail closed with `503 {\"error\":\"pool_cold\"}` (nothing billed,\n   no silent async fallback) — use the async endpoint instead; if a request was accepted\n   but the wait exceeds ~45 s, the job is cooperatively cancelled and refunded —\n   the endpoint returns `503 sync_timeout` (with `Retry-After`). For guaranteed\n   delivery use the async endpoint instead (since 2026-10-20, C3).\n\n2. **Async (any size)** — `POST /v1/audio/jobs` returns **202 Accepted** with a\n  `job_id`; poll `GET /v1/audio/jobs/{id}` every 2–5 s until `completed`, then\n  download the pre-signed `audio_url`. Also accepts bulk submissions\n  (`{\"items\":[…]}`, up to 25).\n## Errors\n\nErrors use a stable `error` code with an optional `message`:\n\n```json\n{ \"error\": \"text_too_long\", \"message\": \"text exceeds 500 characters.\" }\n```\n\nRate-limited requests return `429` with a `Retry-After` header (seconds).\n\n## Argument limits (REST)\n\n- `text`: async ≤ 500 characters; sync `POST /v1/audio/speech` ≤ 300 billable credits.\n- `instruction` ≤ 500 characters; synthesis order style → emotion → instruction (instruction has the final say).\n- `style`: English slugs (Chinese presets accepted as aliases); `emotion`: closed 9-value enum.\n- `clone` needs only `reference_audio` (base64; one request is capped at 4.4 MB ≈ 3 MB of audio — samples of at least 8 s) — `prompt_text` is retired (ignored); `design` uses `gender` + `age`; `speed` was removed (ignored).\n\n## Realtime WebSocket\n\nwss://api.canto-gen.com/v1/audio/realtime\n\nCanto-Gen also exposes a WebSocket realtime endpoint for text-in → streamed PCM audio-out. The protocol is based on OpenAI Realtime; Canto-Gen extension events/fields are marked as such.\n\n```js\n// npm i ws\nconst WebSocket = require(\"ws\");\nconst ws = new WebSocket(\"wss://api.canto-gen.com/v1/audio/realtime\", {\n  headers: { Authorization: `Bearer ${process.env.CANTOGEN_API_KEY}` },\n});\nws.on(\"open\", () => {\n  ws.send(JSON.stringify({ type: \"session.update\", session: { voice: \"sum\", max_session_chars: 500 } }));\n  ws.send(JSON.stringify({ type: \"conversation.item.create\",\n    item: { type: \"message\", content: [{ type: \"input_text\", text: \"你好，歡迎使用港嘢！\" }] } }));\n});\nws.on(\"message\", (raw) => {\n  const ev = JSON.parse(raw.toString());\n  if (ev.type === \"response.output_audio.delta\") play(Buffer.from(ev.delta, \"base64\")); // PCM16 @ 48 kHz\n  if (ev.type === \"response.done\") ws.close();\n});\n```\n\n### Realtime limits\n\n- `idle_timeout_seconds`: 120\n- `max_session_duration_seconds`: 300 (free) / unlimited (paid)\n- `max_sessions_per_user`: free 1 / lite 2 / starter 3 / pro 4 / studio 5 / enterprise 5\n- `max_sessions_global`: 8\n- `max_session_chars_default`: free 2000 / paid 20000\n- `max_in_flight_utterances`: 3\n- `raw_chars_per_session`: 20000\n- `raw_chars_per_minute`: 5000\n- `utterance_lease_seconds`: 60\n- `max_frame_bytes`: 65536\n\nBeyond REST, a realtime WebSocket endpoint (OpenAI Realtime base + Canto-Gen extensions) is available: `wss://api.canto-gen.com/v1/audio/realtime` — see the `x-realtime-websocket` extension and the Realtime WebSocket section of `/v1/docs/en`. An ASR realtime transcription WebSocket is also available: `wss://api.canto-gen.com/v1/asr/realtime` (audio-in → transcript-out, HK$1.40/audio-hour) — see `x-asr-realtime-websocket`."},"servers":[{"url":"https://canto-gen.com","description":"Production"}],"tags":[{"name":"Speech","description":"Text-to-speech generation"},{"name":"Jobs","description":"Speech job status"},{"name":"Voices","description":"Voice catalog"},{"name":"Transcription","description":"Audio transcription"},{"name":"Balance","description":"Account balance"},{"name":"Account","description":"Account lifecycle (deletion / privacy)"}],"paths":{"/v1/audio/speech":{"post":{"operationId":"createSpeech","summary":"Generate speech (sync, short text)","description":"Generate speech synchronously (**fast lane**). Returns `200 OK` with audio bytes.\n\nSync limits: text up to 300 billable credits; `voice` (preset) or\n`design` mode only — `clone` returns `400 clone_not_supported_sync` and\n`srt: true` returns `400 srt_not_supported_sync` (both: use the async\n`POST /v1/audio/jobs`).\n\n**Billing**: this endpoint is the realtime voice API and charges ceil(text characters × 1.3) credits\n(`X-Credits-Charged` includes the multiplier); the realtime WebSocket (per-utterance streaming)\ncharges ceil(characters × 1.5).\n\n**Fast-lane semantics (first come, first served)**: the request is only accepted\nwhen the generation pool is completely idle — it then waits inside the same\nrequest for completion (typically ~5–15 s) and returns the bytes directly.\n**When the pool is busy the endpoint fails closed with `503 {\"error\":\"pool_cold\"}`**\n(nothing is billed) — use the async path. If the wait exceeds ~45 s the job is\ncooperatively cancelled and refunded — the endpoint returns `503 sync_timeout`\n(with `Retry-After`; `X-Request-Id` kept for reconciliation). For guaranteed\ndelivery use the async path.\n\nChoose exactly **one** voice mode — they are mutually exclusive:\n\n- `voice` — a preset voice slug from `GET /v1/voices` (recommended; all preset\n  voices are Cantonese-optimized 香港聲線). Requires the `tts:generate` scope.\n- `design` — describe a synthetic voice by `gender` + `age`. Accepted with the\n  `tts:generate` scope **or** the finer-grained `tts:design` scope (either one\n  suffices, so existing `tts:generate` keys keep working unchanged).\n- `clone` — clone a voice from a base64 `reference_audio` sample (requires the\n  `tts:clone` key scope). Only `reference_audio` is needed; the legacy\n  `prompt_text` field is retired (any value is ignored).\n\nSending more than one of `voice` / `design` / `clone` returns `400 voice_mode_conflict`.","tags":["Speech"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpeechRequest"},"examples":{"PresetVoice":{"summary":"Preset voice (simplest usage)","value":{"model":"cantogen-tts","voice":"sum","text":"你好，歡迎使用港嘢！","response_format":"mp3"}},"DesignedVoice":{"summary":"Designed voice by gender + age (tts:generate or tts:design scope)","value":{"model":"cantogen-tts","design":{"gender":"female","age":"young"},"text":"今日天氣幾好，一齊去行山啦。","style":"brisk","emotion":"happy"}},"DirectedDelivery":{"summary":"Free-text delivery direction","value":{"model":"cantogen-tts","voice":"sum","text":"生日快樂！祝你心想事成！","instruction":"用興奮嘅語氣講，好似慶祝生日咁"}}}}}},"responses":{"200":{"description":"Audio generated successfully. Body contains raw audio bytes.","content":{"audio/mpeg":{"schema":{"type":"string","format":"binary"}}},"headers":{"current-concurrent-requests":{"description":"Current number of in-flight generation requests on the /v1 API lane.","schema":{"type":"string"}},"maximum-concurrent-requests":{"description":"Global concurrency cap for the /v1 API lane (exceeding it returns 429 capacity_exceeded).","schema":{"type":"string"}},"X-Usage-Remaining":{"description":"Credits remaining in the current billing period.","schema":{"type":"string"}},"X-Audio-Duration":{"description":"Audio duration in seconds (when the server can probe the container).","schema":{"type":"string"}},"X-Credits-Charged":{"description":"Credits charged for this request (1 credit = 1 character; multiplier included: realtime sync requests 1.3×; realtime WebSocket per utterance 1.5×).","schema":{"type":"string"}}}},"400":{"description":"Invalid request. See the `error` code for the reason.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"invalid_model":{"value":{"error":"invalid_model","message":"model must be \"cantogen-tts\"."}},"voice_mode_conflict":{"value":{"error":"voice_mode_conflict","message":"voice is mutually exclusive with design and clone."}},"text_required":{"value":{"error":"text_required","message":"text is required."}},"text_too_long":{"value":{"error":"text_too_long","message":"text exceeds 500 characters."}},"unsupported_format":{"value":{"error":"unsupported_format","message":"response_format supports mp3 and wav (MVP)."}},"invalid_emotion":{"value":{"error":"invalid_emotion","message":"emotion must be one of: happy/sad/angry/fearful/disgusted/surprised/neutral/excited/gentle."}},"invalid_instruction":{"value":{"error":"invalid_instruction","message":"instruction exceeds 500 characters."}},"reference_audio_required":{"value":{"error":"reference_audio_required","message":"clone.reference_audio is required."}},"design_gender_and_age_required":{"value":{"error":"design_gender_and_age_required","message":"design.gender and design.age are required."}},"invalid_srt":{"value":{"error":"invalid_srt","message":"srt must be a boolean."}},"clone_not_supported_sync":{"value":{"error":"clone_not_supported_sync","hint":"use POST /v1/audio/jobs (async)"}},"srt_not_supported_sync":{"value":{"error":"srt_not_supported_sync","hint":"use POST /v1/audio/jobs (async)"}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"403":{"description":"Forbidden — the key lacks the required scope (clone requests need `tts:clone`; design requests accept `tts:generate` or `tts:design`), or the key's monthly credit cap is reached.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"insufficient_scope":{"value":{"error":"insufficient_scope","scope":"tts:clone"}},"monthly_cap_reached":{"value":{"error":"monthly_cap_reached","used":100000}}}}}},"429":{"description":"Rate limit exceeded (per user per minute; all keys on the account count together, so creating extra keys does not raise the limit). Wait `Retry-After` seconds and retry.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"rate_limit_exceeded","retry_after":42}}}},"503":{"description":"The sync fast lane is unavailable, by `error` code: `pool_cold` = queue busy, request not accepted (nothing billed, no silent async fallback); `sync_generation_failed` = the job failed or was cancelled during the sync wait (already refunded); `sync_timeout` = the synchronous wait exceeded ~45 s — the job was cooperatively cancelled and will be refunded within seconds (response carries `Retry-After`; retry or go async; `X-Request-Id` is kept for reconciliation); `jobs_unavailable` = transient enqueue outage; `service_unavailable` = the voice engine is temporarily offline (maintenance or backend not ready), request not accepted and nothing billed (response carries `Retry-After`, default 60 s); `rate_limit_unavailable` = the rate-limit infrastructure is temporarily unavailable and the request was refused rather than letting ungoverned traffic through — likewise not accepted and nothing billed (`Retry-After`, default 5 s). For long text or guaranteed delivery submit via `POST /v1/audio/jobs` instead.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"pool_cold","hint":"use POST /v1/audio/jobs (async)"}}}}}}},"/v1/audio/jobs":{"post":{"operationId":"submitSpeechJob","summary":"Submit a speech job (async)","description":"Submit a speech job for asynchronous generation. Returns `202 Accepted` with a\n`job_id` and `poll_url`; poll `GET /v1/audio/jobs/{id}` until the job completes.\n\n**Bulk**: send `{\"items\": [ …up to 25 request bodies… ]}` to submit a batch —\nvalidation is all-or-nothing (any invalid item rejects the whole request with\n`400 invalid_items`); after enqueue, failures are per-item and the `202` body\nreports each entry (`{index, job_id, …}` or `{index, status: \"failed\", error}`).\n\nChoose exactly **one** voice mode — they are mutually exclusive:\n\n- `voice` — a preset voice slug from `GET /v1/voices` (recommended; all preset\n  voices are Cantonese-optimized 香港聲線). Requires the `tts:generate` scope.\n- `design` — describe a synthetic voice by `gender` + `age`. Accepted with the\n  `tts:generate` scope **or** the finer-grained `tts:design` scope (either one\n  suffices, so existing `tts:generate` keys keep working unchanged).\n- `clone` — clone a voice from a base64 `reference_audio` sample (requires the\n  `tts:clone` key scope). Only `reference_audio` is needed; the legacy\n  `prompt_text` field is retired (any value is ignored).\n\nSending more than one of `voice` / `design` / `clone` returns `400 voice_mode_conflict`.","tags":["Jobs"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Retry-safety key. Replaying the same value returns `409 duplicate_request` (nothing resubmitted, nothing re-billed); any string works (UUIDs are used as the job id, other strings are mapped deterministically via UUIDv5). Omit it and every request is a fresh submission.","schema":{"type":"string"},"example":"order-2026-10-15-0001"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpeechRequest"},"examples":{"PresetVoice":{"summary":"Preset voice (simplest usage)","value":{"model":"cantogen-tts","voice":"sum","text":"你好，歡迎使用港嘢！","response_format":"mp3"}},"VoiceClone":{"summary":"Voice clone (requires tts:clone scope)","value":{"model":"cantogen-tts","clone":{"reference_audio":"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA="},"text":"用我把聲講呢句嘢。"}},"BulkBatch":{"summary":"Bulk submission (up to 25 items)","value":{"items":[{"model":"cantogen-tts","voice":"sum","text":"第一句。"},{"model":"cantogen-tts","design":{"gender":"male","age":"adult"},"text":"第二句。"}]}}}}}},"responses":{"202":{"description":"Job accepted. Single request → `{job_id, status, poll_url, srt_requested}`; bulk → `{jobs: [{index, job_id, status, poll_url, srt_requested} | {index, status: \"failed\", error}]}`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatus"},"example":{"job_id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","status":"queued","poll_url":"/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","srt_requested":false}}},"headers":{"X-RateLimit-Limit":{"description":"Per-minute request cap for this key.","schema":{"type":"string"}},"current-concurrent-requests":{"description":"Current number of in-flight generation requests on the /v1 API lane.","schema":{"type":"string"}},"maximum-concurrent-requests":{"description":"Global concurrency cap for the /v1 API lane (exceeding it returns 429 capacity_exceeded).","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window.","schema":{"type":"string"}}}},"400":{"description":"Invalid request — translation-layer codes, or `too_many_items` (>25) / `invalid_items` (bulk all-or-nothing validation).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"too_many_items":{"value":{"error":"too_many_items","max":25,"got":26}},"invalid_items":{"value":{"error":"invalid_items","items":[{"index":1,"error":"text_required"}]}},"voice_mode_conflict":{"value":{"error":"voice_mode_conflict","message":"voice is mutually exclusive with design and clone."}},"text_too_long":{"value":{"error":"text_too_long","message":"text exceeds 500 characters."}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"402":{"description":"The account's credit pool is exhausted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"character_limit_reached","remaining":0}}}},"403":{"description":"Forbidden — the key lacks the required scope (clone requests need `tts:clone`; design requests accept `tts:generate` or `tts:design`), or the key's monthly credit cap is reached.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"insufficient_scope":{"value":{"error":"insufficient_scope","scope":"tts:clone"}},"monthly_cap_reached":{"value":{"error":"monthly_cap_reached","used":100000}}}}}},"429":{"description":"Rate limit exceeded (per user per minute; all keys on the account count together, so creating extra keys does not raise the limit). Wait `Retry-After` seconds and retry.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"rate_limit_exceeded","retry_after":42}}}},"503":{"description":"Transient backend issue (queue/billing infrastructure). Retry with backoff.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"jobs_unavailable"}}}}}},"get":{"operationId":"listSpeechJobs","summary":"List your generation records","description":"List **your own** speech generation records (newest first) — an API-only customer\ncan review what they submitted without logging into the web console.\n\n- **Owner-scoped**: only records belonging to your key are returned (other\n  accounts are never exposed).\n- **Pagination**: `limit` (1–100, default 20) and `before` (the cursor = the `id` of\n  the last record on the previous page). `next_before` is the cursor for the next\n  page; `null` means there is no further page.\n- **`status` filter** (optional): `queued` / `processing` / `cancelling` / `done` /\n  `error` / `cancelled`.\n- **`text_preview`**: a bounded preview (max 120 characters) of the text you\n  generated — read the full text from `GET /v1/audio/jobs/{id}` or keep your own\n  copy.\n- **`duration_ms`**: the audio duration in milliseconds (`null` until the job has\n  produced audio).\n\n⚠️ `status` uses the **raw job status values** (`done` / `error` / …), while the\npolling surface `GET /v1/audio/jobs/{id}` uses the public names\n(`completed` / `failed` / …) — check which endpoint you are reading.","tags":["Jobs"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Records per page (1–100, default 20)."},{"name":"before","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Cursor — the `id` of the last record on the previous page; older records resume from here."},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["queued","processing","cancelling","done","error","cancelled"]},"description":"Return only records in this state (optional; raw job status values)."}],"responses":{"200":{"description":"Your generation records (newest first).","content":{"application/json":{"schema":{"type":"object","properties":{"jobs":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["queued","processing","cancelling","done","error","cancelled"],"description":"Raw job status value."},"created_at":{"type":"string","format":"date-time"},"voice_slug":{"type":"string","nullable":true},"duration_ms":{"type":"integer","nullable":true,"description":"Audio duration in milliseconds (null until the audio exists)."},"text_preview":{"type":"string","description":"Bounded preview (max 120 characters) of the generated text."}},"required":["id","status","created_at","voice_slug","duration_ms","text_preview"]}},"next_before":{"type":"string","nullable":true,"format":"uuid","description":"Cursor for the next page; null when there is no further page."}},"required":["jobs","next_before"]},"example":{"jobs":[{"id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","status":"done","created_at":"2026-10-29T09:12:00.000Z","voice_slug":"sum","duration_ms":4200,"text_preview":"你好，歡迎使用港嘢！"}],"next_before":null}}}},"400":{"description":"Invalid parameter — `invalid_limit` (limit is not an integer between 1 and 100) / `invalid_cursor` (before is not one of your job ids) / `invalid_status` (status is outside the allowed set).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"invalid_limit":{"value":{"error":"invalid_limit"}},"invalid_cursor":{"value":{"error":"invalid_cursor"}},"invalid_status":{"value":{"error":"invalid_status"}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"403":{"description":"Forbidden — the key lacks the required scope (clone requests need `tts:clone`; design requests accept `tts:generate` or `tts:design`), or the key's monthly credit cap is reached.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"insufficient_scope","scope":"tts:generate | tts:design"}}}},"503":{"description":"Transient backend issue (queue/billing infrastructure). Retry with backoff.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"jobs_unavailable"}}}}}}},"/v1/audio/jobs/{id}":{"get":{"operationId":"getJob","summary":"Get job status","description":"Fetch the status of a speech job created with `POST /v1/audio/jobs`.\n\nPoll until `status` becomes `completed` or `failed`. When the job is\n`completed`, `audio_url` carries a pre-signed download URL for the audio.\nWhen subtitles were requested (`srt_requested: true`) and the job completes,\nthe `srt` field carries the SRT subtitle content.","tags":["Jobs"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The `job_id` returned by `POST /v1/audio/jobs`.","schema":{"type":"string"},"example":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b"}],"responses":{"200":{"description":"Current job state. `srt` is attached once subtitles are ready.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatus"},"example":{"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你好，歡迎使用港嘢！"}}}},"400":{"description":"Malformed job id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_job_id"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"404":{"description":"No job exists with this id (or it belongs to another key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"job_not_found"}}}}}},"delete":{"operationId":"cancelJob","summary":"Cancel a job / delete a finished job record","description":"Handles a speech job created with `POST /v1/audio/jobs` via `DELETE`. **Dual\nsemantics**, split by the job's state:\n\n**① The job is still running (`queued`/`processing`/`cancelling`) → cancel**\n(spec §4.4):\n\n- `queued` → `200`: cancelled immediately and **fully refunded** (`refunded` is\n  the actual number of credits refunded).\n- `processing` → `202`: the cancellation was accepted and the job winds down —\n  polling then shows `cancelling` before it settles as `cancelled`.\n\n**② The job has settled (`completed`/`failed`/`cancelled`) → delete the job\nrecord**:\n\n- `200 { id, object: \"job\", deleted: true }`: the job record (including the text\n  you generated), the audio file and its subtitle sidecars (`.srt`/`.vtt`/`.json`)\n  are deleted immediately — there is no restore, and the data is physically\n  purged within 30 days (in practice it is removed right away).\n- Order: the database record is deleted first, then the audio object.\n\nYou can only handle jobs owned by your key; an unknown or foreign id returns\n`404 job_not_found` (which does not reveal whether the id exists).","tags":["Jobs"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The `job_id` returned by `POST /v1/audio/jobs`.","schema":{"type":"string"},"example":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b"}],"responses":{"200":{"description":"The job had not started — cancelled immediately and fully refunded.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","description":"Immediate-cancel body — the job had not started and was fully refunded.","properties":{"ok":{"type":"boolean","description":"Always true on a successful cancel."},"status":{"type":"string","enum":["cancelled"],"description":"Terminal cancel state."},"refunded":{"type":"integer","description":"Characters actually refunded (per usage_events; 0 if the figure could not be read — the refund itself always happens)."}},"required":["ok","status"]},{"type":"object","description":"Deleted-record body — the job had already settled, so its record (including the generated text) and its audio were removed.","properties":{"id":{"type":"string","description":"The id of the job record that was deleted."},"object":{"type":"string","enum":["job"],"description":"Resource type."},"deleted":{"type":"boolean","description":"Always true when the record was deleted."}},"required":["id","object","deleted"]}]},"example":{"ok":true,"status":"cancelled","refunded":1234},"examples":{"cancelled":{"value":{"ok":true,"status":"cancelled","refunded":1234}},"deleted":{"value":{"id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","object":"job","deleted":true}}}}}},"202":{"description":"Cancellation accepted — the in-flight job winds down; polling shows `cancelling` before it settles as `cancelled`.","content":{"application/json":{"schema":{"type":"object","description":"Cancel-intent body — the in-flight job winds down cooperatively; poll until it settles as `cancelled`.","properties":{"ok":{"type":"boolean","description":"Always true when the cancel intent was accepted."},"status":{"type":"string","enum":["cancelling"],"description":"Transient state — poll until it settles as `cancelled`."}},"required":["ok","status"]},"example":{"ok":true,"status":"cancelling"}}}},"400":{"description":"Malformed job id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_job_id"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"404":{"description":"No job exists with this id (or it belongs to another key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"job_not_found"}}}}}}},"/v1/voices/{id}":{"delete":{"operationId":"deleteVoice","summary":"Delete one of your voices","description":"Deletes one of **your own** voices (created with `POST /api/voice-gallery/upload`\nor the voice-design flow). `{id}` may be the voice's `slug` or its uuid `id` —\nboth are identifiers you already have.\n\n- **Effective immediately**: the voice disappears from every listing and can no\n  longer be used for generation.\n- **Permanently purged**: the reference audio and the prepared-reference cache\n  copies of it are deleted right away — there is no restore, and the data is\n  physically purged within 30 days (in practice it is removed right away).\n- **Official preset voices** (the ones from `GET /v1/voices`) can never be\n  deleted — they return `404 not_found`, exactly like a voice owned by another\n  key (the id's existence is never revealed).\n- You can only delete voices owned by your key; an unknown or foreign id returns\n  `404 not_found`.\n- **A generation is still running for this voice** → it is not deleted and the\n  request returns `409 voice_busy` (deleting it would break the running job).\n  Delete it again once that job has finished.\n- Requires the `voices:manage` scope (a key without it returns\n  `403 insufficient_scope`).\n- Order: the database record is deleted first, then the audio object.","tags":["Voices"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The voice's `slug` (e.g. `u-1a2b3c4d-9f8e7d6c`) or its uuid `id`.","schema":{"type":"string"},"example":"u-1a2b3c4d-9f8e7d6c"}],"responses":{"200":{"description":"Deleted — the voice took effect immediately (it no longer appears in any listing and can no longer generate) and its files were purged.","content":{"application/json":{"schema":{"type":"object","description":"Deleted-voice body — the voice was hidden immediately and its files were purged.","properties":{"id":{"type":"string","description":"The uuid of the voice that was deleted."},"object":{"type":"string","enum":["voice"],"description":"Resource type."},"slug":{"type":"string","description":"The slug of the voice that was deleted."},"deleted":{"type":"boolean","description":"Always true when the voice was deleted."}},"required":["id","object","deleted"]},"example":{"id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","object":"voice","slug":"u-1a2b3c4d-9f8e7d6c","deleted":true}}}},"400":{"description":"Invalid id (empty or too long).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_id"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"403":{"description":"The key lacks the `voices:manage` scope.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"insufficient_scope"}}}},"404":{"description":"Unknown id (or owned by another key, or an official preset voice — none of these are distinguished, so the id's existence is never revealed).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"not_found"}}}},"409":{"description":"A generation is still running for this voice, so it was not deleted (that would break the running job). Delete it once the job has finished.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"voice_busy"}}}},"503":{"description":"Temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"deletion_failed"}}}}}}},"/v1/voices":{"get":{"operationId":"listVoices","summary":"List voices","description":"List the official voice catalog. All preset voices are\n**Cantonese-optimized** (廣東話優化) — use each voice's `slug` as the\n`voice` parameter of `POST /v1/audio/speech` (sync) or `POST /v1/audio/jobs` (async).\n\n`preview_url` streams a short sample without extra auth.","tags":["Voices"],"security":[],"responses":{"200":{"description":"The official voice catalog.","content":{"application/json":{"schema":{"type":"object","properties":{"voices":{"type":"array","items":{"$ref":"#/components/schemas/Voice"}}},"required":["voices"]},"example":{"voices":[{"slug":"sum","name":"心妍","gender":"female","age":"adult","styles":[],"preview_url":"https://canto-gen.com/api/voice-clip?slug=sum"}]}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"503":{"description":"Voice catalog temporarily unavailable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"voice_gallery_unavailable"}}}}}}},"/v1/asr/transcribe":{"post":{"operationId":"createTranscription","summary":"Transcribe audio to text","description":"Upload an audio file and receive the transcript synchronously in the same request.\nCanonical path: `/v1/asr/transcribe` — OpenAI-compatible clients may keep using\n\n**Fields**: `file` (required, the audio file), `model` (required), `language`\n(optional, ISO-639-1), `response_format` (`json` default / `text`). `srt` and\n`verbose_json` need segment-level timestamps and are not yet supported — they\nreturn `400 unsupported_response_format`. Transcription is currently\nCantonese-optimized.\n\n**Billing (prepaid HK$ balance)**: billed by audio duration — HK$0.80 per hour\nfor file transcription, charged per second with no rounding up. The estimated\namount is **held** before processing and settled on the actual duration when\ndone; failures, timeouts and outages are refunded in full (success-only billing).\nAn insufficient balance returns `402 insufficient_balance` — top up at\n[/account/billing](/account/billing) and retry. `GET /v1/balance` returns your\nbalance and the current rates.\n\n**Auth**: any valid API key works on the ASR surface (including jobs, retrieval and\nthe realtime token — there is no separate `asr:` scope; single-key model).\n\n**Limits (sync-window pregate, C3 2026-10-20)**: the effective per-file cap on the\nsynchronous route is **4.5 MB** (platform request-body limit; exceeding it returns a\nplatform-level 413 — compress first with 16 kHz mono Opus 16k, one file fits ≈60 minutes\nof audio); the **synchronous delivery cap is 8 minutes (480 seconds) of audio** —\nprobed duration over it returns `400 audio_too_long` (with `duration_seconds` +\n`async_endpoint`, rejected before processing — nothing billed); the per-request audio\npolicy cap is 4 hours (over it returns `413 payload_too_large`).\n\n**No daily audio cap** (every plan alike, free included, since 2026-10-30): usage is bounded\nonly by the prepaid balance — each request holds the measured duration up front and settles\non the actual duration afterwards.\n\n**Long-audio path**: `POST /v1/asr/jobs` (async, `audio_url`/`storage_path` up to 500 MB / 4 hours) —\nreturns `202 queued` immediately; poll `GET /v1/asr/transcriptions/{id}` for the\ntranscript. Same wallet billing semantics.","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/TranscriptionRequest"},"examples":{"AudioFile":{"summary":"Transcribe an audio file","value":{"file":"(binary audio file)","model":"cantogen-asr","response_format":"json"}}}}}},"responses":{"200":{"description":"Transcription succeeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Transcription"},"example":{"id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","object":"transcription","text":"今日天氣幾好，一齊去行山啦。","duration_seconds":1830,"charged_hkd":0.4067}}}},"400":{"description":"Invalid request — `invalid_request` (multipart missing `file`/`model`, an empty file, or an unsupported audio type), `unsupported_response_format` (`response_format` currently supports `json`/`text` only), or `audio_too_long` (audio exceeds the 8-minute/480-second synchronous delivery cap — the response carries `duration_seconds` + `async_endpoint: /v1/asr/jobs`; rejected before processing, nothing billed. Use POST /v1/asr/jobs for long audio).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"invalid_request":{"value":{"error":"invalid_request","message":"file field is required."}},"unsupported_response_format":{"value":{"error":"unsupported_response_format","message":"Supported formats: json, text. Segment-timestamp formats are not yet supported."}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"402":{"description":"The prepaid balance cannot cover the hold for this transcription (nothing was processed, nothing billed). Top up at /account/billing and retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"insufficient_balance","message":"Top up your prepaid balance and retry."}}}},"413":{"description":"The audio exceeds the platform request-body cap (4.5 MB) — compress first (16 kHz mono Opus 16k, ≈33–40 min per 4.5 MB) or use POST /v1/asr/jobs (audio_url/storage_path up to 500 MB / 4 hours, async).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"payload_too_large","message":"Audio exceeds the 4.5 MB request-body cap — compress (Opus 16k) or use POST /v1/asr/jobs."}}}},"503":{"description":"The transcription service is temporarily unavailable (duration-probe, billing or pipeline failure; engine queueing/degradation within the sync window also lands here — transient, carries `Retry-After`, retry shortly). Any hold is refunded in full — success-only billing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"engine_unavailable":{"value":{"error":"engine_unavailable"}},"billing_unavailable":{"value":{"error":"engine_unavailable","message":"billing_unavailable"}}}}}}}}},"/v1/asr/jobs":{"post":{"operationId":"createAsrJob","summary":"Submit a transcription job (upfront async)","description":"Returns 202 queued immediately — no sync wait. Three entry modes: multipart `file` (≤4.5 MB platform cap), JSON `audio_url` (server-side fetch — bypasses the 4.5 MB cap; https only, up to 500 MB / 4 hours, private addresses rejected), or JSON `storage_path` (browser session only — the playground large-file lane; API-key callers sending it get 400). Then poll `GET /v1/asr/transcriptions/{id}`: queued → processing → completed (`text` / `duration_seconds` present) / failed (`error` present). The wallet hold is settled on actual duration the first time completed is observed (idempotent). Billing matches /v1/asr/transcribe: HK$0.80/hour, per-second, success-only.","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/TranscriptionRequest"},"examples":{"AudioFile":{"summary":"Transcribe an audio file","value":{"file":"(binary audio file)","model":"cantogen-asr","response_format":"json"}}}},"application/json":{"schema":{"$ref":"#/components/schemas/AsrJobUrlRequest"},"examples":{"AudioUrl":{"summary":"audio_url entry","value":{"audio_url":"https://example.com/long-audio.ogg","model":"cantogen-asr"}},"StoragePath":{"summary":"storage_path entry (browser session only)","value":{"storage_path":"asr-uploads/free/user-uuid/018f3c2a-7b12-4c5d-9e8f-a1b2c3d4e5f6.wav","model":"cantogen-asr"}}}}}},"responses":{"202":{"description":"Queued — poll GET /v1/asr/transcriptions/{id} for the transcript.","content":{"application/json":{"example":{"id":"1cabbd7b-741d-46a1-b83e-ed259247a57c","object":"transcription","status":"queued","retrieval":"/v1/asr/transcriptions/1cabbd7b-741d-46a1-b83e-ed259247a57c"}}}},"400":{"description":"Invalid parameters / audio URL rejected (https only, private hosts, 500 MB cap); malformed storage_path (bad shape, sent together with audio_url, or sent by an API-key caller).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"invalid_request":{"value":{"error":"invalid_request"}},"audio_url_must_be_https":{"value":{"error":"audio_url_must_be_https"}},"audio_url_host_not_allowed":{"value":{"error":"audio_url_host_not_allowed"}},"audio_url_too_large":{"value":{"error":"audio_url_too_large"}},"storage_path_session_only":{"value":{"error":"storage_path_session_only"}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"402":{"description":"Insufficient prepaid balance (nothing queued, zero charge).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"insufficient_balance"}}}},"403":{"description":"storage_path does not belong to the authenticated user (cross-account guard).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"storage_path_forbidden"}}}},"404":{"description":"The object referenced by storage_path does not exist (upload incomplete or past its TTL — 24 hours on free / 7 days on paid).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"storage_path_not_found"}}}},"413":{"description":"Audio exceeds the 4-hour cap.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"payload_too_large"}}}},"503":{"description":"Submit infrastructure temporarily unavailable — refunded; retry after Retry-After.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"asr_queue_submit_failed"}}}}}}},"/v1/asr/transcriptions/{id}":{"get":{"operationId":"getTranscriptionById","summary":"Retrieve a long-audio transcription (poll surface for async 202)","description":"Async transcription retrieval — poll this endpoint for the transcript: queued → processing → completed (`text` / `duration_seconds` present) / failed (`error` present). The wallet hold is settled on actual duration the first time completed is observed (idempotent — re-polling never double-charges). You can only retrieve results owned by your key. Submit via `POST /v1/asr/jobs` (returns 202 queued).","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The `id` returned by the 202 response."}],"responses":{"200":{"description":"Current status (text / duration_seconds present when completed). Jobs submitted with `vocabulary` (grant-gated) have recitation residue stripped from `text` before it is returned; jobs without a vocabulary list return the transcript verbatim.","content":{"application/json":{"example":{"id":"0117bfb9-215a-4559-a94f-e27b525d600c","object":"transcription","status":"completed","text":"多謝大家抽空過嚟，我哋即刻開始今日嘅環節。嗯。","duration_seconds":631.2,"error":null,"created_at":"2026-10-18T10:00:00.000Z","completed_at":"2026-10-18T10:04:12.000Z"}}}},"400":{"description":"Invalid id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_id"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"404":{"description":"Unknown id (or owned by another key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"not_found"}}}},"503":{"description":"Temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"retrieval_failed"}}}}}},"delete":{"operationId":"deleteTranscription","summary":"Delete a transcription (including its audio)","description":"Deletes one transcription: the transcript and the uploaded audio take effect immediately, there is no restore, and the data is physically purged within 30 days (in practice it is removed right away). You can only delete records owned by your key (records owned by another key return 404, which does not reveal whether the id exists). Order: the database record is deleted first, then the audio object.","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The `id` returned by the 202 response (= `asr_long_transcriptions.request_id`)."}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"example":{"id":"0117bfb9-215a-4559-a94f-e27b525d600c","object":"transcription","deleted":true}}}},"400":{"description":"Invalid id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_id"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"404":{"description":"Unknown id (or owned by another key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"not_found"}}}},"503":{"description":"Temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"deletion_failed"}}}}}}},"/v1/asr/vocabulary":{"get":{"operationId":"getAsrVocabulary","summary":"Get your account's keyword vocabulary","description":"Reads the ASR keyword vocabulary for your account (one list, per account). `terms` is the cleaned term list (each term ≤30 characters, at most 50 terms); `updated_at` is when it was last changed (null if never set). The list is used for keyword biasing: API key requests (`sk_`) to `POST /v1/asr/transcribe` and `POST /v1/asr/jobs` apply it automatically when the request does **not** carry a `vocabulary` parameter; a request that carries one (including an empty array) takes precedence. The keyword vocabulary feature must be enabled for your account (otherwise 403).","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Your account's vocabulary (empty if never set).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsrVocabulary"},"example":{"scope":"api","terms":["證監會","庫務局"],"updated_at":"2026-10-28T09:00:00.000Z"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"403":{"description":"The keyword vocabulary feature is not enabled for this account.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"asr_vocabulary_not_granted","message":"Keyword vocabulary is not enabled for this account. Ask Canto-Gen support to enable the asr_vocabulary feature for your account, then retry."}}}},"503":{"description":"Temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"vocabulary_read_failed"}}}}}},"put":{"operationId":"putAsrVocabulary","summary":"Replace your account's keyword vocabulary","description":"Replaces the ASR keyword vocabulary for your account (whole-list replacement, not an increment). `terms` is cleaned first (trimmed, de-duplicated, terms longer than 30 characters dropped, at most 50 kept) and then stored; the response echoes what was actually stored. Sending an empty array `{\"terms\": []}` clears the list (the `DELETE` method does the same). The keyword vocabulary feature must be enabled for your account (otherwise 403).","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsrVocabularyRequest"},"examples":{"Terms":{"summary":"Two keyword terms","value":{"terms":["證監會","庫務局"]}},"Clear":{"summary":"Clear the list (same as DELETE)","value":{"terms":[]}}}}}},"responses":{"200":{"description":"Saved (echoes the stored value).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsrVocabulary"},"example":{"scope":"api","terms":["證監會","庫務局"],"updated_at":"2026-10-28T09:00:00.000Z"}}}},"400":{"description":"Malformed body (not valid JSON, `terms` missing, or `terms` is not an array of strings).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"terms_required":{"value":{"error":"terms_required"}},"invalid_terms":{"value":{"error":"invalid_terms"}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"403":{"description":"The keyword vocabulary feature is not enabled for this account.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"asr_vocabulary_not_granted","message":"Keyword vocabulary is not enabled for this account. Ask Canto-Gen support to enable the asr_vocabulary feature for your account, then retry."}}}},"503":{"description":"Temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"vocabulary_write_failed"}}}}}},"delete":{"operationId":"deleteAsrVocabulary","summary":"Clear your account's keyword vocabulary","description":"Clears the ASR keyword vocabulary for your account (same as `PUT {\"terms\": []}` — the list becomes empty and `updated_at` is refreshed). API key transcription requests that carry no `vocabulary` parameter will then apply no terms. The keyword vocabulary feature must be enabled for your account (otherwise 403).","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Cleared.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsrVocabulary"},"example":{"ok":true,"scope":"api","terms":[],"updated_at":"2026-10-28T09:00:00.000Z"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"403":{"description":"The keyword vocabulary feature is not enabled for this account.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"asr_vocabulary_not_granted","message":"Keyword vocabulary is not enabled for this account. Ask Canto-Gen support to enable the asr_vocabulary feature for your account, then retry."}}}},"503":{"description":"Temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"vocabulary_write_failed"}}}}}}},"/v1/balance":{"get":{"operationId":"getBalance","summary":"Get prepaid balance","description":"Get the account's prepaid balance (HKD) plus the file-transcription / realtime-\ndictation rates and the auto top-up settings.\n\n- `balance_hkd` — the current prepaid balance (HKD).\n- `asr` — rates: HK$0.80/hour for file transcription, HK$1.40/hour for realtime\n  dictation, billed per audio second (`audio_second`).\n- `auto_topup` — auto top-up settings (enabled, threshold, amount).\n\nTest keys report the test-environment balance (isolated from production).\nTop up at [/account/billing](/account/billing) when the balance runs low.","tags":["Balance"],"security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Prepaid balance and rates.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Balance"},"example":{"object":"balance","currency":"HKD","balance_hkd":50,"balance_hkd_exact":49.9975,"asr":{"batch_rate_hkd_per_hour":0.8,"realtime_rate_hkd_per_hour":1.4,"billed_unit":"audio_second"},"auto_topup":{"enabled":true,"threshold_hkd":20,"amount_hkd":100}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"503":{"description":"Balance lookup temporarily failed. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"balance_read_failed"}}}}}}},"/v1/usage":{"get":{"operationId":"listUsage","summary":"Usage ledger (prepaid wallet entries)","description":"List entries from your prepaid wallet ledger (the append-only source of truth) — every credit/debit carries a kind (top_up / hold / hold_release / refund / adjustment…), a signed 4-decimal amount and the balance after it; usage rows also carry unit, quantity and the rate at charge time. `hold` and `hold_release` appear in pairs (estimated hold → settled on actual duration). The response also carries `summary` (current-period usage): `period_start` (start of the current billing period — the subscription anniversary day for paid plans, not the 1st of the calendar month), `used_chars` (characters consumed this period), `limit_chars` (the plan's quota for this period), `remaining_chars` (characters left), `credit_chars` (credit pool) and `balance_hkd` / `balance_hkd_exact` (prepaid wallet balance) — so you can reconcile against your own system without summing every entry. Cursor pagination: `limit` (1–100, default 20) and `starting_after` (the last entry id of the previous page). Test keys report the test-environment ledger. Amounts are 4-decimal exact (/v1/balance remains the 2-decimal display surface).","tags":["Balance"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Entries per page (1–100, default 20)."},{"name":"starting_after","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Cursor — the id of the last entry on the previous page; older entries resume from here."}],"responses":{"200":{"description":"A page of ledger entries (newest first).","content":{"application/json":{"schema":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"summary":{"type":"object","description":"Current-period usage summary (for reconciliation) — same billing-period basis, no need to sum every entry.","properties":{"period_start":{"type":"string","nullable":true,"description":"Start of the current billing period (YYYY-MM-DD) — the subscription anniversary day for paid plans, not the 1st of the calendar month. Null only when the meter could not be resolved."},"used_chars":{"type":"number","description":"Characters consumed in the current billing period (plan quota + credit pool)."},"limit_chars":{"type":"number","description":"The plan's monthly credit quota for the current period."},"remaining_chars":{"type":"number","description":"Credits left in the current period (remaining plan quota + credit pool) — the same figure the console shows."},"credit_chars":{"type":"number","description":"Credit pool in credits (credit pack / legacy top-up balance)."},"currency":{"type":"string","enum":["HKD"],"description":"Always `HKD`."},"balance_hkd":{"type":"number","description":"Current prepaid wallet balance in HKD (2-decimal display — same as /v1/balance)."},"balance_hkd_exact":{"type":"number","description":"The same wallet balance at 4 decimals — sub-cent charges stay auditable."},"asr_seconds_by_operation":{"type":"object","description":"Transcription seconds billed in the current billing period, split by operation — file transcription and realtime dictation are separate rates and never merged into one total. Absent only if the transcription meter could not be read.","properties":{"asr_batch":{"type":"number","description":"File transcription seconds billed this period (HKD 0.80 per audio hour)."},"asr_realtime":{"type":"number","description":"Realtime dictation seconds billed this period (HKD 1.40 per audio hour)."}},"additionalProperties":false}},"required":["period_start","used_chars","limit_chars","remaining_chars","credit_chars","currency","balance_hkd","balance_hkd_exact","balance_credits"]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"object":{"type":"string","enum":["wallet_ledger_entry"]},"created_at":{"type":"string","format":"date-time"},"kind":{"type":"string","enum":["top_up","hold","hold_release","refund","chargeback_withdrawal","chargeback_reinstatement","adjustment"]},"amount_hkd":{"type":"number","description":"Signed amount in HKD (4dp; + credit / − debit)."},"balance_after_hkd":{"type":"number","description":"Wallet balance right after this entry (HKD, 4dp)."},"unit":{"type":"string","nullable":true,"enum":["character","audio_second",null]},"quantity":{"type":"number","nullable":true},"rate_hkd_per_hour":{"type":"number","nullable":true,"description":"Rate at charge time (audio-second rows)."},"note":{"type":"string","nullable":true}},"required":["id","object","created_at","kind","amount_hkd","balance_after_hkd"]}},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true,"format":"uuid"}},"required":["object","summary","data","has_more","next_cursor"]},"example":{"object":"list","summary":{"period_start":"2026-10-10","used_chars":2339,"limit_chars":370000,"remaining_chars":367661,"credit_chars":5765,"currency":"HKD","balance_hkd":49.98,"balance_hkd_exact":49.9775,"balance_credits":49977.5},"data":[{"id":"3fcf4ca5-a996-813f-9714-c45e59549a24","object":"wallet_ledger_entry","created_at":"2026-10-17T10:34:00.000Z","kind":"hold_release","amount_hkd":0.0001,"balance_after_hkd":49.9775,"unit":"audio_second","quantity":11.46,"rate_hkd_per_hour":0.8,"note":null}],"has_more":true,"next_cursor":"3f9f4ca5-a996-8149-a631-d7c4351b7bb3"}}}},"400":{"description":"Invalid parameter.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_cursor"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"503":{"description":"Ledger temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"usage_read_failed"}}}}}}},"/v1/asr/realtime/token":{"post":{"operationId":"mintAsrRealtimeToken","summary":"Mint an ASR realtime ephemeral token (browser use)","description":"Mints a short-lived `cst_…` token so browsers can open `wss://api.canto-gen.com/v1/asr/realtime` without exposing the permanent `sk_` key. Tokens are HMAC-signed, scoped to `asr:realtime`, and live 60–600 seconds (default 300). Always mint server-side (with your `sk_` key or an authenticated session) and pass the token to the browser via the `?token=` query parameter — never put your permanent API key in a URL. Optional `vocabulary` (keyword biasing, gated by the `asr_vocabulary` entitlement; silently ignored without it) — both `sk_` and session callers may pass it.","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AsrRealtimeTokenRequest"},"examples":{"Default":{"summary":"Default 300s","value":{"expires_in_seconds":300}}}}}},"responses":{"200":{"description":"Token minted.","content":{"application/json":{"example":{"token":"cst_eyJzdWIiOiJ1MSIsInNjb3BlIjoiYXNyOnJlYWx0aW1lIn0.x8Y","expires_in":300,"ws_url":"wss://api.canto-gen.com/v1/asr/realtime","token_type":"cst"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"429":{"description":"Rate limited (30/minute per user; 15/minute per key — all keys on the account count together).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"rate_limited"}}}}}}},"/v1/account/deletion":{"post":{"operationId":"deleteAccount","summary":"Delete your account","description":"Delete **your own** account (self-service deletion) — available to API-only\ncustomers without the web console.\n\n**Two-step confirmation** (body `{ confirmEmail, confirmWord }`):\n\n- `confirmEmail` — must equal your account email (case-insensitive);\n- `confirmWord` — must equal `DELETE` (**case-sensitive**).\n\nA mismatch returns `400` (`confirm_missing` / `confirm_email_mismatch` /\n`confirm_word_mismatch`).\n\n**Preconditions** (`409`):\n\n- `active_jobs` — you still have unfinished generation jobs (`queued` /\n  `processing` / `cancelling`);\n- `team_owner` — you own a workspace that still has other members (dissolve or\n  hand it over first).\n\n**Success** (`200 { ok: true, purge_by }`):\n\n- **Deactivated immediately**: the account is locked (login banned, every existing\n  session invalidated).\n- **Permanently purged**: your generation records, voices, reference audio and other\n  personal data are removed by the daily cleanup job; `purge_by` is the deadline by\n  which the purge completes (legally retained billing and usage records are kept).\n\nRepeat requests are idempotent (one deletion request per account).","tags":["Account"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDeletionRequest"},"examples":{"Confirmation":{"summary":"Two-step confirmation","value":{"confirmEmail":"you@example.com","confirmWord":"DELETE"}}}}}},"responses":{"200":{"description":"Deletion request recorded — the account is deactivated immediately and the data is removed by the daily cleanup job.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"purge_by":{"type":"string","format":"date-time","description":"Deadline by which the personal data is purged."}},"required":["ok","purge_by"]},"example":{"ok":true,"purge_by":"2026-11-02T03:00:00.000Z"}}}},"400":{"description":"The two-step confirmation did not match — `confirm_missing` / `confirm_email_mismatch` / `confirm_word_mismatch`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"confirm_missing":{"value":{"error":"confirm_missing"}},"confirm_email_mismatch":{"value":{"error":"confirm_email_mismatch"}},"confirm_word_mismatch":{"value":{"error":"confirm_word_mismatch"}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"409":{"description":"A precondition failed — `active_jobs` (you still have unfinished generation jobs) / `team_owner` (you own a workspace that still has other members).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"active_jobs":{"value":{"error":"active_jobs"}},"team_owner":{"value":{"error":"team_owner"}}}}}},"503":{"description":"The deletion request could not be recorded temporarily. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"deletion_request_failed"}}}}}}},"/v1/account/telemetry":{"get":{"operationId":"getTelemetryPreference","summary":"Quality-signal (anonymous usage stats) setting","description":"Read or update this account's **quality-signal opt-out** (anonymous usage stats).\n\n- `telemetry_opt_out: true` turns off **automatic behavioural telemetry** (for\n  example how you use the service);\n- feedback you submit on purpose (ratings, reports) is **not affected** — that is an\n  explicit request, not telemetry.\n\nThis is one setting per account (the same `user_profiles.telemetry_opt_out`\nfor every key on the account).","tags":["Account"],"security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"The current quality-signal setting.","content":{"application/json":{"schema":{"type":"object","properties":{"telemetry_opt_out":{"type":"boolean","description":"True = automatic behavioural telemetry is turned off."}},"required":["telemetry_opt_out"]},"example":{"telemetry_opt_out":false}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"503":{"description":"The setting is temporarily unavailable. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"telemetry_read_failed"}}}}}},"put":{"operationId":"setTelemetryPreference","summary":"Quality-signal (anonymous usage stats) setting","description":"Read or update this account's **quality-signal opt-out** (anonymous usage stats).\n\n- `telemetry_opt_out: true` turns off **automatic behavioural telemetry** (for\n  example how you use the service);\n- feedback you submit on purpose (ratings, reports) is **not affected** — that is an\n  explicit request, not telemetry.\n\nThis is one setting per account (the same `user_profiles.telemetry_opt_out`\nfor every key on the account).","tags":["Account"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelemetryPreference"},"examples":{"OptOut":{"summary":"Turn anonymous usage stats off","value":{"telemetry_opt_out":true}}}}}},"responses":{"200":{"description":"The setting was saved.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"telemetry_opt_out":{"type":"boolean"}},"required":["ok","telemetry_opt_out"]},"example":{"ok":true,"telemetry_opt_out":true}}}},"400":{"description":"Invalid body — `invalid_telemetry_opt_out` (telemetry_opt_out is not a boolean).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_telemetry_opt_out"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"503":{"description":"The setting could not be saved temporarily. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"telemetry_update_failed"}}}}}}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"http","scheme":"bearer","bearerFormat":"sk_live_","description":"All endpoints require an API key passed as a Bearer token, except `GET /v1/voices`\n(the public voice catalog, anonymous access allowed):\n\n```\nAuthorization: Bearer sk_live_…\n```\n\nCreate and manage keys in the playground under 「API 金鑰」 (API Keys).\nAll keys share the `sk_live_…` prefix; free-plan keys are subject to a daily\ngeneration limit, while paid-plan usage counts against the key's monthly credit cap."}},"schemas":{"SpeechRequest":{"type":"object","description":"Choose exactly **one** voice mode — they are mutually exclusive:\n\n- `voice` — a preset voice slug from `GET /v1/voices` (recommended; all preset\n  voices are Cantonese-optimized 香港聲線). Requires the `tts:generate` scope.\n- `design` — describe a synthetic voice by `gender` + `age`. Accepted with the\n  `tts:generate` scope **or** the finer-grained `tts:design` scope (either one\n  suffices, so existing `tts:generate` keys keep working unchanged).\n- `clone` — clone a voice from a base64 `reference_audio` sample (requires the\n  `tts:clone` key scope). Only `reference_audio` is needed; the legacy\n  `prompt_text` field is retired (any value is ignored).\n\nSending more than one of `voice` / `design` / `clone` returns `400 voice_mode_conflict`.","properties":{"model":{"type":"string","enum":["cantogen-tts"],"default":"cantogen-tts","description":"The TTS model. Currently only `cantogen-tts` exists; other values return `400 invalid_model`."},"voice":{"type":"string","description":"Preset voice slug from `GET /v1/voices`. Required unless `design` or `clone` is used."},"text":{"type":"string","minLength":1,"maxLength":500,"description":"The text to speak (1–500 characters on the async endpoint; the sync `POST /v1/audio/speech` caps at 300 billable credits). Cantonese text works best — 廣東話字正腔圓. Supports inline tone tags: insert `[label]` in the script, e.g. `[笑聲]你好[唷!]` — closed set of 15 tags (see the API guide, Inline tone tags). Unknown bracket tokens pass through untouched."},"pitch":{"type":"integer","minimum":-12,"maximum":12,"default":0,"deprecated":true,"description":"DEPRECATED (2026-09-20) — no effect under raw audio delivery. Still accepted with unchanged range validation (−12…+12 semitones, default 0), but the value is ignored."},"volume":{"type":"number","minimum":0,"maximum":10,"default":1,"deprecated":true,"description":"DEPRECATED (2026-09-20) — no effect under raw audio delivery. Still accepted with unchanged range validation (0–10, default 1), but the value is ignored."},"emotion":{"type":"string","enum":["happy","sad","angry","fearful","disgusted","surprised","neutral","excited","gentle"],"description":"Optional speaking emotion/style."},"style":{"type":"string","maxLength":120,"example":"brisk","description":"Delivery style. Canonical values are English slugs — rhythm: steady / composed / rapid / slow / drawl / brisk / dynamic / staccato; timbre: mellow / cute / sharp / lazy / breathy / hoarse / deep / crisp / feeble / trembling; scene: corporate / promo / wedding / guide / expo / ad / tutorial / news / travel / audiobook / latenight / product / fairytale / podcast / storytelling / sports; character: animation-hero / stylish-uncle / astronaut / support-agent / boss / grandma / lottery-host / news-anchor / sage / late-dj / teacher / storyteller / market-commentator / sports-commentator / girl-next-door / smart-kid. The original Chinese preset values (明快 / 圓潤 / 講古 …) are still accepted as aliases — both are normalized internally to the same canonical value before reaching the engine. Any other free text also passes through. Merged into the engine style slot (order: style → emotion → instruction)."},"instruction":{"type":"string","maxLength":500,"example":"用興奮嘅語氣講，好似慶祝生日咁","description":"Free-text delivery direction in Cantonese, Chinese or English — e.g. 「用興奮嘅語氣講，好似慶祝生日咁」. Applied after `style` and `emotion`, so it has the final say on how the line is delivered. Values over 500 characters return `400 invalid_instruction`."},"response_format":{"type":"string","enum":["mp3","wav"],"default":"mp3","description":"Audio container format. Other formats return `400 unsupported_format`."},"srt":{"type":"boolean","default":false,"description":"Also generate SRT subtitles. When `true`, a completed job's `srt` field carries the subtitle content (billed at a higher credit rate)."},"seed":{"type":"integer","description":"Reserved. Accepted and validated as an integer but not yet used for reproducibility."},"sample_rate":{"type":"integer","description":"Reserved. Accepted and validated as an integer but not yet applied."},"design":{"$ref":"#/components/schemas/DesignObject"},"clone":{"$ref":"#/components/schemas/CloneObject"}},"required":["text"]},"DesignObject":{"type":"object","description":"Describe a synthetic voice instead of picking a preset. Accepted with the `tts:generate` or the `tts:design` key scope (either one suffices).","properties":{"gender":{"type":"string","enum":["male","female"],"description":"Voice gender."},"age":{"type":"string","enum":["child","teenage","adult","midage","elder","teen","young"],"description":"Voice age band. Prefer `child`, `teenage`, `adult`, `midage`, `elder`. `teen` (alias of `teenage`) and `young` (alias of `adult`) are deprecated but still accepted."}},"required":["gender","age"]},"CloneObject":{"type":"object","description":"Clone a voice from a reference sample. Requires an API key with the `tts:clone` scope.","properties":{"reference_audio":{"type":"string","description":"Base64 audio sample (data URL such as `data:audio/wav;base64,…` or raw base64). Use a clean sample of at least 8 seconds — shorter samples are rejected; long samples are auto-trimmed to ~15–20 s on sentence boundaries."}},"required":["reference_audio"]},"JobStatus":{"type":"object","description":"State of a speech job. Returned by `GET /v1/audio/jobs/{id}` (200) and as the `202` body of `POST /v1/audio/jobs`. Async generation is submitted via `POST /v1/audio/jobs`; the sync `POST /v1/audio/speech` returns audio bytes directly (200).","properties":{"job_id":{"type":"string","description":"Job id (UUID)."},"status":{"type":"string","enum":["queued","processing","cancelling","completed","failed","cancelled"],"description":"Job lifecycle state. `cancelling` is transient — after `DELETE /v1/audio/jobs/{id}` on an in-flight job it settles as `cancelled`."},"poll_url":{"type":"string","description":"Relative URL of the job poll endpoint for this job."},"srt_requested":{"type":"boolean","description":"Whether the job was submitted with `srt: true`."},"srt":{"type":"string","description":"SRT subtitle content. Present only when subtitles were requested and the job has completed with captions materialized. Additive — its absence never affects other fields."},"audio_url":{"type":"string","format":"uri","description":"Pre-signed download URL for the generated audio. Present only once `status` is `completed` — URLs from the materialized library are valid for 1 hour, worker URLs (not yet materialized) for 7 days. Additive — its absence never affects other fields."}},"required":["job_id","status","poll_url","srt_requested"]},"Voice":{"type":"object","description":"A preset voice. All official voices are Cantonese-optimized (廣東話優化).","properties":{"slug":{"type":"string","description":"Use as the `voice` parameter of `POST /v1/audio/speech` (sync) or `POST /v1/audio/jobs` (async)."},"name":{"type":"string","description":"Display name (Chinese name as-is)."},"gender":{"type":"string","description":"Voice gender."},"age":{"type":"string","description":"Voice age range."},"styles":{"type":"array","items":{"type":"string"},"description":"Supported speaking styles/emotions."},"preview_url":{"type":"string","description":"URL streaming a short preview sample (no extra auth needed)."}},"required":["slug","name"]},"TranscriptionRequest":{"type":"object","description":"Transcribe audio to text","properties":{"file":{"type":"string","format":"binary","description":"The audio file to transcribe (wav, mp3, m4a/mp4, webm, ogg, flac). Max 4.5 MB effective (platform request-body cap); for longer audio use POST /v1/asr/jobs with audio_url or storage_path (up to 500 MB)."},"model":{"type":"string","description":"Transcription model name. Required by the API shape; any non-empty value is accepted — the current release offers a single Cantonese-optimized transcription model (`cantogen-asr`)."},"language":{"type":"string","description":"Optional ISO-639-1 language hint. Accepted as-is; transcription is currently Cantonese-optimized."},"response_format":{"type":"string","enum":["json","text"],"default":"json","description":"`json` (default) returns the structured object; `text` returns plain text. Segment-timestamp formats (`srt`, `verbose_json`) are not yet supported and return `400 unsupported_response_format`."},"vocabulary":{"type":"array","items":{"type":"string"},"maxItems":50,"description":"Optional keyword-biasing list (up to 50 terms, each ≤30 chars after trim). Grant-gated: only accounts with the `asr_vocabulary` entitlement have it applied — without the grant the field is silently ignored and the request is processed exactly as if it were absent. On multipart requests send a JSON-encoded string array (e.g. '[\"term1\",\"term2\"]'). Stored default (API key requests): when the parameter is **omitted**, the vocabulary stored for your account (PUT /v1/asr/vocabulary) is applied automatically; when the parameter is present — including an empty array — the request value wins (an empty array means no biasing, it does not fall back to the stored list)."}},"required":["file","model"]},"AsrJobUrlRequest":{"type":"object","description":"Async submit (JSON entry) — `audio_url`: the server fetches the audio, bypassing the 4.5 MB upload cap; `storage_path`: browser session only (the playground large-file lane — first upload via a signed URL from /api/asr/upload-url; objects expire after 24 hours on free / 7 days on paid). Exactly one of the two is required; sending both → 400.","properties":{"audio_url":{"type":"string","format":"uri","description":"https URL of the audio file (server-side fetch; up to 500 MB / 4 hours)."},"storage_path":{"type":"string","description":"Upload path as returned by POST /api/asr/upload-url (browser session callers only — API-key requests are rejected with 400 storage_path_session_only). The path must belong to the authenticated user; a cross-account path returns 403 storage_path_forbidden. Max 500 MB / 4 hours of audio."},"model":{"type":"string","enum":["cantogen-asr"]},"language":{"type":"string","description":"ISO-639-1 language hint (optional)."},"vocabulary":{"type":"array","items":{"type":"string"},"maxItems":50,"description":"Optional keyword-biasing list (up to 50 terms, each ≤30 chars after trim). Grant-gated: only accounts with the `asr_vocabulary` entitlement have it applied — without the grant the field is silently ignored and the job is processed exactly as if it were absent. When granted, the stored transcript has recitation residue stripped before it is returned by the poll endpoint. Stored default (API key requests): when the parameter is **omitted**, the vocabulary stored for your account (PUT /v1/asr/vocabulary) is applied automatically; when the parameter is present — including an empty array — the request value wins (an empty array means no biasing, it does not fall back to the stored list)."},"callback_url":{"type":"string","format":"uri","description":"Optional https webhook — POSTed { id, status, duration_seconds?, error? } when the job reaches completed/failed (2xx = delivered; failed attempts retried up to 5 times). Signed with X-Signature: sha256=<hex>(HMAC-SHA256 of the raw body, shared ASR_WEBHOOK_SECRET) + X-Timestamp when configured."}},"required":[]},"AsrRealtimeTokenRequest":{"type":"object","description":"Token TTL in seconds. 60–600, default 300.","properties":{"expires_in_seconds":{"type":"number","description":"Token TTL in seconds. 60–600, default 300.","example":300},"vocabulary":{"type":"array","items":{"type":"string"},"maxItems":50,"description":"Optional keyword-biasing list for this session (up to 50 terms, each ≤30 chars after trim). Grant-gated: only accounts with the `asr_vocabulary` entitlement have it applied — without the grant the list is silently ignored and the token is minted exactly as if it were absent. Per-request only: this endpoint does not apply your stored vocabulary (PUT /v1/asr/vocabulary) — pass the terms you want in the request."}}},"Transcription":{"type":"object","description":"Transcription succeeded.","properties":{"id":{"type":"string","description":"Transcription request id (UUID)."},"object":{"type":"string","enum":["transcription"],"description":"Always `transcription`."},"text":{"type":"string","description":"The transcribed text."},"duration_seconds":{"type":"number","description":"Audio duration in seconds (rounded to 2 decimals) — the billing basis."},"charged_hkd":{"type":"number","description":"HKD actually settled for this request (success-only billing, per-second, no rounding up). Additive — its absence never affects other fields."}},"required":["id","object","text"]},"AsrVocabularyRequest":{"type":"object","description":"The keyword vocabulary (whole-list replacement).","properties":{"terms":{"type":"array","items":{"type":"string"},"maxItems":50,"description":"The terms to store — up to 50 terms, each ≤30 characters after trimming. The list is cleaned before it is stored (trimmed, de-duplicated, over-long terms dropped, capped at 50). An empty array clears the list."}},"required":["terms"]},"AsrVocabulary":{"type":"object","description":"Your account's keyword vocabulary.","properties":{"scope":{"type":"string","enum":["api"],"description":"Always `api` — the public API vocabulary scope."},"terms":{"type":"array","items":{"type":"string"},"description":"The stored terms (cleaned)."},"updated_at":{"type":"string","description":"ISO-8601 timestamp of the last change (null when the list was never set)."}},"required":["scope","terms"]},"Balance":{"type":"object","description":"Prepaid balance and rates.","properties":{"object":{"type":"string","enum":["balance"],"description":"Always `balance`."},"currency":{"type":"string","enum":["HKD"],"description":"Prepaid-balance currency."},"balance_hkd":{"type":"number","description":"The current prepaid balance in HKD (2-decimal display; test keys report the test-environment balance)."},"balance_hkd_exact":{"type":"number","description":"The same balance at 4 decimals (2026-10-18 additive) — sub-cent charges (e.g. 0.0025) stay auditable."},"asr":{"type":"object","description":"Get prepaid balance","properties":{"batch_rate_hkd_per_hour":{"type":"number","description":"File transcription rate: HKD per audio hour (currently 0.8)."},"realtime_rate_hkd_per_hour":{"type":"number","description":"Realtime dictation rate: HKD per audio hour (currently 1.4 — 1.75× the file transcription rate)."},"batch_rate_credits_per_hour":{"type":"number","description":"File transcription rate in credits per audio hour (currently 800 — 1 credit = HK$0.001)."},"realtime_rate_credits_per_hour":{"type":"number","description":"Realtime dictation rate in credits per audio hour (currently 1,400)."},"billed_unit":{"type":"string","enum":["audio_second"],"description":"Billing unit — per audio second, no rounding up."}},"required":["batch_rate_hkd_per_hour","realtime_rate_hkd_per_hour","billed_unit"]},"auto_topup":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether auto top-up is enabled."},"threshold_hkd":{"type":"number","description":"Balance threshold (HKD) that triggers a top-up."},"amount_hkd":{"type":"number","description":"Top-up amount (HKD)."},"threshold_credits":{"type":"number","description":"Same threshold in credits (1 credit = HK$0.001)."},"amount_credits":{"type":"number","description":"Same top-up amount in credits (1 credit = HK$0.001)."}},"required":["enabled","threshold_hkd","amount_hkd"]}},"required":["object","currency","balance_hkd","asr","auto_topup"]},"AccountDeletionRequest":{"type":"object","description":"The two-step confirmation body for deleting an account.","properties":{"confirmEmail":{"type":"string","description":"Your account email (compared case-insensitively)."},"confirmWord":{"type":"string","enum":["DELETE"],"description":"Must be exactly `DELETE` (case-sensitive)."}},"required":["confirmEmail","confirmWord"]},"TelemetryPreference":{"type":"object","description":"The quality-signal opt-out body.","properties":{"telemetry_opt_out":{"type":"boolean","description":"True = turn automatic behavioural telemetry off."}},"required":["telemetry_opt_out"]},"Error":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]}}},"security":[{"ApiKeyAuth":[]}],"x-realtime-websocket":{"url":"wss://api.canto-gen.com/v1/audio/realtime","protocol":"OpenAI Realtime base + documented Canto-Gen extensions","description":"Canto-Gen also exposes a WebSocket realtime endpoint for text-in → streamed PCM audio-out. The protocol is based on OpenAI Realtime; Canto-Gen extension events/fields are marked as such.","auth":{"header":"Authorization: Bearer sk_…","queryFallback":["?api_key=","?token="],"scope":"tts:generate","note":"Same `sk_` API key as REST (scope `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=` (alias `?token=`). Query parameters are written to URLs, server logs and browser history, so keep them browser-only; server-side code must always use the header."},"clientEvents":[{"type":"session.update","origin":"openai","description":"Open/update the session: voice, language, output_audio_format, max_session_chars, tts.segmentation (`max_session_chars` may also be supplied at connect time via the `?max_session_chars=` query parameter; it is always clamped to the plan cap)."},{"type":"conversation.item.create","origin":"openai","description":"Send one explicit utterance (input_text); triggers synthesis."},{"type":"input_text.append","origin":"cantogen-extension","description":"Raw token deltas — stream as you generate."},{"type":"input_text.commit","origin":"cantogen-extension","description":"Force a segment boundary (flush now)."},{"type":"response.create","origin":"openai","description":"Trigger synthesis."},{"type":"response.cancel","origin":"openai","description":"Barge-in: stop the current utterance and refund it."},{"type":"session.close","origin":"cantogen-extension","description":"Close the session and settle."}],"serverEvents":[{"type":"session.created","origin":"openai","description":"Session opened; carries the server-minted session id and the **effective** `max_session_chars` (already clamped to the plan cap — 2,000 free / 20,000 paid)."},{"type":"session.updated","origin":"openai","description":"Config ack."},{"type":"response.created","origin":"openai","description":"An utterance begins."},{"type":"response.output_audio.delta","origin":"openai","description":"A base64 PCM16 @ 48 kHz audio chunk."},{"type":"response.output_audio.done","origin":"openai","description":"Utterance audio complete."},{"type":"response.done","origin":"openai","description":"Utterance done; usage carries input_chars / billed_chars / remaining (extension fields)."},{"type":"error","origin":"openai","description":"{type, code, message, http_status}."}],"segmentationNote":"`tts.segmentation` (extension) controls segmentation: `{\"mode\":\"server\"}` (default) splits on punctuation server-side, so the client just keeps appending tokens; `{\"mode\":\"client\"}` lets the client mark boundaries with `conversation.item.create` / `input_text.commit`.","usageExtensionFields":["input_chars","billed_chars","remaining"],"billingNote":"Billing reuses the same credit meter, per utterance (reserve → complete/refund), charging ceil(input characters × 1.5) (WS realtime multiplier as of 2026-11-08); the per-session character cap (max_session_chars) is measured on input characters, without the multiplier. `response.done` returns the utterance usage (`billed_chars` = actually billed, multiplier included) and remaining balance.","limits":{"idle_timeout_seconds":"120","max_session_duration_seconds":"300 (free) / unlimited (paid)","max_sessions_per_user":"free 1 / lite 2 / starter 3 / pro 4 / studio 5 / enterprise 5","max_sessions_global":"8","max_session_chars_default":"free 2000 / paid 20000","max_in_flight_utterances":"3","raw_chars_per_session":"20000","raw_chars_per_minute":"5000","utterance_lease_seconds":"60","max_frame_bytes":"65536"},"errors":[{"code":"invalid_api_key","http_status":401},{"code":"rate_limit_exceeded","http_status":429},{"code":"sandbox_daily_limit","http_status":429},{"code":"monthly_cap_reached","http_status":429},{"code":"character_limit_reached","http_status":402},{"code":"concurrency_limit_reached","http_status":429},{"code":"session_char_limit_exceeded","http_status":403},{"code":"raw_limit_exceeded","http_status":413},{"code":"text_too_long","http_status":400},{"code":"invalid_session","http_status":400},{"code":"engine_unavailable","http_status":503}],"example":"// npm i ws\nconst WebSocket = require(\"ws\");\nconst ws = new WebSocket(\"wss://api.canto-gen.com/v1/audio/realtime\", {\n  headers: { Authorization: `Bearer ${process.env.CANTOGEN_API_KEY}` },\n});\nws.on(\"open\", () => {\n  ws.send(JSON.stringify({ type: \"session.update\", session: { voice: \"sum\", max_session_chars: 500 } }));\n  ws.send(JSON.stringify({ type: \"conversation.item.create\",\n    item: { type: \"message\", content: [{ type: \"input_text\", text: \"你好，歡迎使用港嘢！\" }] } }));\n});\nws.on(\"message\", (raw) => {\n  const ev = JSON.parse(raw.toString());\n  if (ev.type === \"response.output_audio.delta\") play(Buffer.from(ev.delta, \"base64\")); // PCM16 @ 48 kHz\n  if (ev.type === \"response.done\") ws.close();\n});"},"x-asr-realtime-websocket":{"url":"wss://api.canto-gen.com/v1/asr/realtime","protocol":"OpenAI Realtime base + documented Canto-Gen extensions","description":"Realtime speech-to-text WebSocket: stream PCM audio in over one long-lived connection and receive transcription (partial deltas + final done). Built for live captions, voice input and meeting transcription. The protocol is based on OpenAI Realtime; Canto-Gen extension events/fields are marked as such.","auth":{"header":"Authorization: Bearer sk_…","queryFallback":["?token=<ephemeral cst_…> (browser)","?api_key=<sk_…> (server-side only)"],"scope":"any valid sk_ key (ASR scope 未設 — 過渡期)","note":"Server-side: `Authorization: Bearer sk_…` header. Browsers: never put the permanent API key in a URL — mint a short-lived ephemeral token (`cst_…`, 60–600s) server-side via `POST /v1/asr/realtime/token` and open the WS with `?token=<cst_…>` (mirrors AssemblyAI/OpenAI)."},"audioFormat":"pcm16le_mono_16k (16 kHz / mono / signed 16-bit LE, base64 inside JSON append frames; 32,000 bytes = 1 second)","clientEvents":[{"type":"session.update","description":"First event after connect — validates the model: `{\"type\":\"session.update\",\"model\":\"asr\"}` (proxied to the engine)."},{"type":"input_audio_buffer.commit","description":"`final:false` opens the transcription stream (before any append); `final:true` ends it — the engine answers with `transcription.done`."},{"type":"input_audio_buffer.append","description":"Audio chunks: `{\"type\":\"input_audio_buffer.append\",\"audio\":\"<base64 PCM>\"}` × N. ≤640KB per frame and ≤640KB/s average (20× realtime)."},{"type":"response.cancel","description":"Cancel the in-flight transcription (proxied)."}],"serverEvents":[{"type":"gateway.welcome","description":"First frame after upgrade: session_id / rate_hkd_per_hour / billing_tick_seconds / audio_format."},{"type":"session.created","description":"Engine session created (proxied)."},{"type":"transcription.delta","description":"Partial transcription delta (proxied from the engine)."},{"type":"transcription.done","description":"Final full transcript + token usage (after `final:true`)."},{"type":"gateway.close","description":"Last frame before close: code + reason (so clients that never surface WS close codes still learn why)."}],"billingNote":"Billed by audio duration from the prepaid HK$ balance — realtime dictation HK$1.40/audio-hour, per second, no rounding up (minimum 1 second). Balance is checked before connect (below one tick → 4002); an in-arrears hold runs every 60 seconds mid-session (insufficient → 4009 graceful close); on disconnect the actual audio seconds are settled and the difference released (billed == audio, success-only). **No session-duration cap** (removed for every plan on 2026-10-30): the guards are the 60-second billing tick, the 120-second idle timeout and closing the moment the balance runs out. Realtime usage is accounted separately from batch transcription usage. GET /v1/balance returns balance and rates.","limits":{"max_sessions_global":"10","max_sessions_per_key":"free 2 / paid 4","max_session_duration_seconds":"none (unbounded — no session-length cap)","idle_timeout_seconds":"120","max_frame_bytes":"655360","max_bytes_per_second":"640000","billing_tick_seconds":"60"},"closeCodes":[{"code":1000,"reason":"completed","meaning":"Normal completion (done received)."},{"code":1001,"reason":"idle_timeout","meaning":"No client frame within the idle window."},{"code":1008,"reason":"protocol_violation","meaning":"Bad protocol / binary frames / invalid base64 / audio rate exceeded."},{"code":1009,"reason":"message_too_big","meaning":"Single frame above 640KB."},{"code":1011,"reason":"engine_lost","meaning":"Engine disconnected mid-session."},{"code":1013,"reason":"engine_unavailable","meaning":"Engine unreachable (after retries)."},{"code":4001,"reason":"invalid_or_expired_token","meaning":"Key invalid / expired / revoked."},{"code":4002,"reason":"insufficient_balance","meaning":"Balance below one tick before connect."},{"code":4006,"reason":"billing_unavailable","meaning":"Billing side down for consecutive ticks (never bill-free)."},{"code":4008,"reason":"capacity_exceeded","meaning":"Concurrent session cap hit (global 10 / per-key free 2 / paid 4)."},{"code":4009,"reason":"insufficient_balance","meaning":"Balance insufficient for the next hold mid-session — graceful close."}],"example":["// npm i ws","const WebSocket = require(\"ws\");","const ws = new WebSocket(\"wss://api.canto-gen.com/v1/asr/realtime\", {","  headers: { Authorization: `Bearer ${process.env.CANTOGEN_API_KEY}` },","});","ws.on(\"open\", () => {","  ws.send(JSON.stringify({ type: \"session.update\", model: \"asr\" }));","  ws.send(JSON.stringify({ type: \"input_audio_buffer.commit\", final: false }));","  // stream 16 kHz mono PCM16, base64 — 32,000 bytes = 1 second:","  ws.send(JSON.stringify({ type: \"input_audio_buffer.append\", audio: pcmChunk.toString(\"base64\") }));","});","ws.on(\"message\", (raw) => {","  const ev = JSON.parse(raw.toString());","  if (ev.type === \"transcription.delta\") process.stdout.write(ev.delta);","  if (ev.type === \"transcription.done\") { console.log(ev.text); ws.close(); }","});"]}}