SaydiVoice API reference
This page renders interactively with JavaScript. The complete reference is below, and is also available as plain text at /llms-full.txt.
Base URL https://voice.saydi.ai/api. Authenticate with Authorization: Bearer sv_live_….
Introduction
How to get SaydiVoice into your system — even if you do not write code.
SaydiVoice reads text out loud as natural speech. Use it right here in the Studio, or let your own system or AI call it for you.
In short: integration is 3 steps — 1. Get an access key. 2. Get a voice code. 3. Paste one instruction into your AI, and it writes the connection for you. No coding needed — start at No code needed.
Which one is you?
You only need two things — An access key (a secret starting with sv_live_) and a voice code (which voice to read with, e.g. vi-adam). Both are covered in Get a voice ID.
No code needed
Three steps to have an AI wire SaydiVoice into your system.
You do not need to read code or learn any technical words. You need one AI tool that can write code — Claude Code, Codex, Antigravity, DeepSeek, Cursor — and these three steps. Hit a strange word? Keep the glossary open in another tab.
- Get your access key — Press the Developer console button at the top right of this page — it opens the Developers screen. Open its API Keys tab, then press Create key. A code starting with
sv_live_ appears — copy it. It is shown only once, so paste it somewhere safe (Notes, password manager). - Get your voice code — Open the Studio, point at the voice you like and click the copy icon (the tooltip says Copy voice ID). The code looks like
vi-adam. Not sure which voice? Just use vi-adam. - Paste the instruction into your AI — Open your AI tool, paste the whole box below and send. The AI reads the official documentation and writes the connection for you.
Want your own voice instead? — The Studio has a voice-cloning section and it needs no code: clone your voice there, then copy the voice code exactly like step 2.
The instruction to paste
Tiếng Việt
Bạn hãy đọc tài liệu chính thức của SaydiVoice ở địa chỉ:
https://voice.saydi.ai/llms-full.txt
Sau đó giúp tôi tích hợp SaydiVoice (đọc văn bản thành giọng nói) vào dự án này.
Yêu cầu:
- Khoá truy cập: đọc từ biến môi trường SAYDI_API_KEY. Tôi sẽ tự điền khoá — đừng hỏi tôi khoá và đừng viết khoá thẳng vào code.
- Mã giọng mặc định: vi-adam. Tôi có thể đổi sang mã khác lấy từ GET /samples, hoặc mã vc_... nếu là giọng nhân bản.
- Viết một hàm speak(text, voice_id) trả về nội dung file audio, gọi POST /v1/audio/speech.
- Kết quả trả về là audio, không phải JSON. Nếu nhận được JSON thì đó là lỗi.
- Gặp lỗi 429 thì chờ theo header Retry-After rồi thử lại.
- Chỉ dùng endpoint, tham số và mã lỗi có trong tài liệu trên. Thiếu thông tin thì hỏi lại tôi, đừng tự đoán.
- Cuối cùng chỉ cho tôi chính xác cần dán khoá vào file nào.
English
Read the official SaydiVoice documentation at:
https://voice.saydi.ai/llms-full.txt
Then help me integrate SaydiVoice (text to speech) into this project.
Requirements:
- Access key: read it from the SAYDI_API_KEY environment variable. I will fill it in myself — do not ask me for the key and never hardcode it.
- Default voice code: vi-adam. I may switch to another one from GET /samples, or a vc_... code for a cloned voice.
- Write one function speak(text, voice_id) that returns the audio file content, calling POST /v1/audio/speech.
- The response is audio, not JSON. If you get JSON, it is an error.
- On 429, wait for the Retry-After header and retry.
- Use only the endpoints, parameters and error codes in that documentation. If something is missing, ask me instead of guessing.
- Finally, tell me exactly which file to put the key in.
Do not paste your access key into an AI chat — The instruction above tells the AI to read the key from a settings file, so you never have to share it. Keep the key private — it is the same as a password.
How to know it worked
- You can type a sentence in your product and hear it read out loud.
- The sound plays — it is a real audio file, not an error message.
- The voice is the one you picked. Wrong voice usually means the voice code was not recognised — check it in Get a voice ID.
No AI tool? Send this to your developer
Copy the message below and send it to whoever builds your system (email, Zalo, chat). Send the access key in a separate message, never in the same one.
Handoff message
Hello, we would like to use SaydiVoice for text to speech.
Integration docs: https://voice.saydi.ai/docs/
Full machine-readable copy: https://voice.saydi.ai/llms-full.txt
What needs to be done:
- Call POST https://voice.saydi.ai/api/v1/audio/speech
header: Authorization: Bearer <key>
body: {"model":"tts-1","voice":"<voice id>","input":"<text>"}
- The response is audio bytes; errors return JSON.
- For voice cloning: POST /v1/voices (multipart, with consent=true) to get a vc_... id, then use that id above.
- On 429, wait for the Retry-After header and retry.
I will send the access key in a separate message.
Quick Start
For developers: make your first request in 3 steps.
This page is for developers — Base URL https://voice.saydi.ai/api, and every request carries Authorization: Bearer sv_live_.... If you do not write code, use No code needed instead — an AI does this for you.
- Create an API key — Open the Developers screen, switch to its API Keys tab, create a key and copy the secret — it is shown once.
- Store it in an environment variable —
export SAYDI_API_KEY="sv_live_..." on the server. Never commit it or ship it in client-side code. - Pick a voice ID —
vi-adam works out of the box. To choose another voice, list the catalogue in Get a voice ID. - Make your first request — POST to /v1/audio/speech and write the response body to a file.
cURL
export SAYDI_API_KEY="sv_live_REPLACE_WITH_YOUR_KEY"
curl -sS -X POST "https://voice.saydi.ai/api/v1/audio/speech" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","voice":"vi-adam","input":"Xin chào, đây là API của SaydiVoice."}' \
--output hello.mp3
# Inspect what you were charged (characters) and how long it took:
# -D - prints the response headers, e.g. X-OV-Chars: 41
Python
import os, requests
API_KEY = os.environ["SAYDI_API_KEY"]
BASE = "https://voice.saydi.ai/api"
r = requests.post(
f"{BASE}/v1/audio/speech",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "tts-1",
"voice": "vi-adam",
"input": "Xin chào, đây là API của SaydiVoice.",
},
timeout=120,
)
r.raise_for_status()
open("hello.mp3", "wb").write(r.content)
print("chars charged:", r.headers.get("X-OV-Chars"))
Node.js
import { writeFile } from "node:fs/promises";
const API_KEY = process.env.SAYDI_API_KEY;
const BASE = "https://voice.saydi.ai/api";
const res = await fetch(`${BASE}/v1/audio/speech`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "tts-1",
voice: "vi-adam",
input: "Xin chào, đây là API của SaydiVoice.",
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("hello.mp3", Buffer.from(await res.arrayBuffer()));
console.log("chars charged:", res.headers.get("X-OV-Chars"));
Expect audio bytes — Success returns audio bytes (audio/mpeg or audio/wav). A JSON response means an error.
Get a voice ID
What a voice code is and where to get one — easiest is copying it from the Studio.
A voice code is a short code that says which voice should read your text, for example vi-adam. There are two kinds:
| Kind | Looks like | Where it comes from |
|---|
| A ready-made voice | vi-adam | Copy it from the Studio voice list, or list them with the API |
| Your own cloned voice | vc_9f2c1a4b... | Clone it in the Studio and copy the code, or upload it through the API (see Clone a voice, then call it) |
Easiest: copy it from the Studio
- Open the Studio — Press the Studio button at the top right of this page.
- Find a voice you like — Use the voice list, press play to listen to each one.
- Click the copy icon — Hover a voice and click copy — the tooltip says Copy voice ID. You now have a code like
vi-adam. - Paste it where a voice code is asked for — For example into the AI instruction on the No code needed page.
Cannot choose? Use vi-adam — It works out of the box. You can switch to another voice code later without changing anything else.
For developers: list them with the API
cURL
curl -sS "https://voice.saydi.ai/api/samples" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
| jq -r '.[] | [.voice_id, .display_name, .language] | @tsv'
# vi-adam Adam — Giọng hot tiktok vi
# vi-cuppy Cuppy — Nữ miền Nam vi
# Narrow the list, e.g. Vietnamese voices only, 20 max:
curl -sS "https://voice.saydi.ai/api/samples?lang=vi&limit=20" \
-H "Authorization: Bearer $SAYDI_API_KEY"
Python
import os, requests
API_KEY = os.environ["SAYDI_API_KEY"]
r = requests.get(
"https://voice.saydi.ai/api/samples",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
r.raise_for_status()
for v in r.json():
print(v["voice_id"], v["display_name"], v["language"], sep="\t")
Node.js
const API_KEY = process.env.SAYDI_API_KEY;
const res = await fetch("https://voice.saydi.ai/api/samples", {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
for (const v of await res.json()) {
console.log([v.voice_id, v.display_name, v.language].join("\t"));
}
200 — An array of voices. Use voice_id in your requests; display_name is only a label.
[
{
"name": "adam-11labs-vi",
"voice_id": "vi-adam",
"display_name": "Adam — Giọng hot tiktok",
"gender": "male",
"language": "vi",
"locale": "vi-VN",
"use_case": "conversational",
"featured": true
}
]
Use the code, not the display name — voice_id is what the API expects. The display name (e.g. "Adam — Giọng hot tiktok") is only a label and is not guaranteed to stay the same.
Hear it before you commit
cURL
# Catalogue voice: streams its reference clip.
curl -sS "https://voice.saydi.ai/api/samples/vi-adam/audio" \
-H "Authorization: Bearer $SAYDI_API_KEY" --output preview.wav
# Cloned voice: a short sentence, rendered once and cached. Not charged.
curl -sS "https://voice.saydi.ai/api/v1/voices/$VOICE_ID/preview" \
-H "Authorization: Bearer $SAYDI_API_KEY" --output preview.mp3
Python
import os, requests
API_KEY = os.environ["SAYDI_API_KEY"]
VOICE_ID = os.environ["VOICE_ID"]
H = {"Authorization": f"Bearer {API_KEY}"}
# Catalogue voice: streams its reference clip.
r = requests.get("https://voice.saydi.ai/api/samples/vi-adam/audio", headers=H, timeout=30)
r.raise_for_status()
open("preview.wav", "wb").write(r.content)
# Cloned voice: a short sentence, rendered once and cached. Not charged.
r = requests.get(f"https://voice.saydi.ai/api/v1/voices/{VOICE_ID}/preview", headers=H, timeout=60)
r.raise_for_status()
open("preview.mp3", "wb").write(r.content)
Node.js
import { writeFile } from "node:fs/promises";
const API_KEY = process.env.SAYDI_API_KEY;
const VOICE_ID = process.env.VOICE_ID;
const H = { Authorization: `Bearer ${API_KEY}` };
// Catalogue voice: streams its reference clip.
let res = await fetch("https://voice.saydi.ai/api/samples/vi-adam/audio", { headers: H });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("preview.wav", Buffer.from(await res.arrayBuffer()));
// Cloned voice: a short sentence, rendered once and cached. Not charged.
res = await fetch(`https://voice.saydi.ai/api/v1/voices/${VOICE_ID}/preview`, { headers: H });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("preview.mp3", Buffer.from(await res.arrayBuffer()));
Call a voice
For developers: send text plus a voice code, get an audio file back.
This is the whole integration. Send text and the voice ID you picked; write the response body to a file. Everything else on this site is optional.
cURL
curl -sS -X POST "https://voice.saydi.ai/api/v1/audio/speech" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","voice":"vi-adam","input":"Xin chào, đây là giọng Adam."}' \
-D headers.txt --output hello.mp3
grep -i 'x-ov-voice\|x-ov-chars' headers.txt
Python
import os, requests
r = requests.post(
"https://voice.saydi.ai/api/v1/audio/speech",
headers={"Authorization": f"Bearer {os.environ['SAYDI_API_KEY']}"},
json={
"model": "tts-1",
"voice": "vi-adam", # any voice_id from GET /samples
"input": "Xin chào, đây là giọng Adam.",
},
timeout=120,
)
r.raise_for_status() # a JSON body here means an error
open("hello.mp3", "wb").write(r.content)
print("voice:", r.headers["X-OV-Voice"], "chars:", r.headers["X-OV-Chars"])
Node.js
import { writeFile } from "node:fs/promises";
const res = await fetch("https://voice.saydi.ai/api/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAYDI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "tts-1",
voice: "vi-adam", // any voice_id from GET /samples
input: "Xin chào, đây là giọng Adam.",
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("hello.mp3", Buffer.from(await res.arrayBuffer()));
console.log("voice:", res.headers.get("X-OV-Voice"), "chars:", res.headers.get("X-OV-Chars"));
OpenAI SDK
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["SAYDI_API_KEY"],
base_url="https://voice.saydi.ai/api/v1", # only this line changes
)
with client.audio.speech.with_streaming_response.create(
model="tts-1",
voice="vi-adam", # a SaydiVoice voice_id
input="Xin chào, đây là giọng Adam.",
) as response:
response.stream_to_file("hello.mp3")
Confirm which voice actually spoke
Every audio response carries X-OV-Voice (the voice that really spoke, after any fallback) and X-OV-Chars (characters charged). Log them: if X-OV-Voice is not the ID you sent, your voice value did not resolve — see the callout below.
Common changes
| I want to | Change this |
|---|
| Use a different voice | voice: any voice_id from GET /samples or a cloned vc_... id |
| Return WAV instead of MP3 | response_format: wav (also opus, flac, pcm, aac) |
| Speak slower or faster | speed: 0.25–4.0, default 1.0 |
| Render faster / higher quality | model: tts-1 (16 steps, faster) or tts-1-hd (32 steps, better) |
| Speak more than 4096 characters | Use POST /tts — same sample field, no 4096-character limit |
| Design a voice from a description | instructions on this endpoint, or instruct on POST /tts |
OpenAI voice names do not select a voice here — alloy, nova, shimmer and the other OpenAI names are accepted for compatibility, but none of them exist in this catalogue — they all resolve to one default voice (vi-adam unless configured otherwise) and the response says X-OV-Voice-Source: openai-alias-default. To actually choose a voice, pass a SaydiVoice voice_id.
Authentication
Authenticate with one API key.
Send the key as Authorization: Bearer sv_live_.... Never put it in URLs, logs or client-side code.
Request headers
Authorization: Bearer sv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Use your AI tool
Which tool to open, what to paste, and how to check — per tool.
This page is the per-tool detail for No code needed: copy the instruction there first, then find your tool below.
Find your tool
| Your tool | How to open it | What to do |
|---|
| Claude Code | The Anthropic coding app, opened on your project folder | Paste the instruction. It can open the documentation link itself. |
| Codex (OpenAI) | The Codex coding app on your project | Paste the instruction. It can open the link itself. |
| Antigravity (Google) | Open your project and switch on the agent panel | Paste the instruction. If it cannot open the link, attach the file (below). |
| Cursor, Windsurf and other AI editors | Open the project and turn on Agent / Chat mode | Paste the instruction. |
| DeepSeek, ChatGPT and other web chats | Open the chat page | A web chat cannot open links — attach the file (below) with the instruction. |
If the AI cannot open the link
Open the link below in your browser and save the page, then send that file in the same message as the instruction.
Documentation file
https://voice.saydi.ai/llms-full.txt
# Trong trình duyệt: mở link trên rồi bấm Ctrl+S (Cmd+S trên Mac) để lưu thành file.
# Gửi file vừa lưu kèm lời nhắc ở trang "Không cần biết code".
A good task to give it
Tiếng Việt
Thêm chức năng đọc văn bản thành giọng nói vào dự án này.
- Tạo file src/tts/saydivoice.py (đổi cho phù hợp nếu dự án không dùng Python).
- Hàm: synthesize(text: str, voice_id: str = "vi-adam", fmt: str = "mp3") -> bytes
- Gọi POST https://voice.saydi.ai/api/v1/audio/speech, header Authorization: Bearer <SAYDI_API_KEY>.
- Nếu chưa có SAYDI_API_KEY thì báo lỗi thật rõ, đừng chạy tiếp.
- Lỗi 4xx/5xx thì ném lỗi kèm nội dung error.message trong JSON.
- Gặp 429 thì thử lại một lần theo header Retry-After.
- Trả về nguyên bytes audio, không giải mã thành văn bản.
- Thêm một test giả lập lời gọi HTTP.
English
Add a Vietnamese voice-over helper to this project.
- File: src/tts/saydivoice.py (adapt if this project is not Python).
- Function: synthesize(text: str, voice_id: str = "vi-adam", fmt: str = "mp3") -> bytes
- POST https://voice.saydi.ai/api/v1/audio/speech with Authorization: Bearer <SAYDI_API_KEY>.
- Fail fast with a clear message if SAYDI_API_KEY is not set.
- On 4xx/5xx raise an error that includes the JSON error.message.
- Retry once on 429 using Retry-After.
- Return raw audio bytes; never decode them as text.
- Add one test that mocks the HTTP call.
Check the result
- Real sound comes out, not an error message.
- The voice is the one you picked. If it sounds wrong, the voice code was not recognised — see Get a voice ID.
- The access key is not written inside the code — the AI should tell you which file to put it in.
- It waits and retries by itself when it is sending too fast.
Every page has a Copy for AI button — Copy for AI at the top of any page copies a ready-to-paste instruction plus that page's documentation — handy when you only need one part, for example cloning a voice.
Manage keys
Create, list, rotate and revoke API keys.
These routes manage keys and require a signed-in user token, not an API key.
GET /api-keys — List keys
Returns every key on the account. Secrets and hashes are never included.
cURL
curl -sS "https://voice.saydi.ai/api/api-keys" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN"
200 — The account's keys, newest first.
{
"ok": true,
"max_keys": 20,
"keys": [
{
"id": "key_1f0c9a2b7d3e4f56",
"name": "Production",
"prefix": "sv_live_Ab12Cd",
"scopes": ["tts"],
"status": "active",
"created_at": 1758844800.0,
"last_used_at": 1758848400.0,
"expires_at": null,
"revoked_at": null
}
]
}
POST /api-keys — Create a key
Creates a key and returns the secret exactly once, in the key field.
| Name | Type | Required | Description |
|---|
name | string | no | A label to recognise the key later. Defaults to "Default key". |
scopes | string[] | no | Reserved for later releases. Defaults to ["tts"]. |
cURL
curl -sS -X POST "https://voice.saydi.ai/api/api-keys" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Production"}'
201 — Created. Store key now — it cannot be read again.
{
"ok": true,
"id": "key_1f0c9a2b7d3e4f56",
"name": "Production",
"prefix": "sv_live_Ab12Cd",
"scopes": ["tts"],
"status": "active",
"created_at": 1758844800.0,
"key": "sv_live_Ab12CdEf34Gh56Ij78Kl90Mn12Op34Qr"
}
409 — Per-account key limit reached. Revoke an unused key first.
{"detail": "API key limit reached (20). Revoke an unused key first."}
DELETE /api-keys/{key_id} — Revoke a key
Revokes immediately. The key stops authenticating on the next request.
cURL
curl -sS -X DELETE "https://voice.saydi.ai/api/api-keys/key_1f0c9a2b7d3e4f56" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN"
200 — Revoked.
{"ok": true, "id": "key_1f0c9a2b7d3e4f56", "status": "revoked"}
404 — No such key on this account.
{"detail": "API key not found."}
POST /api-keys/{key_id}/rotate — Rotate a key
Issues a new secret for the same key id. The previous secret stops working immediately.
Keeps the key id and name, replaces only the secret.
cURL
curl -sS -X POST "https://voice.saydi.ai/api/api-keys/KEY_ID/rotate" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json"
GET /api-keys/status — Key management availability
Public readiness probe: whether key management is deployed, the per-account cap, and the known scopes.
cURL
curl -sS "https://voice.saydi.ai/api/api-keys/status"
200 — Readiness snapshot.
{"available": true, "max_keys": 20, "scopes": ["tts", "voices"]}
POST /v1/audio/speech
OpenAI-compatible text to speech.
OpenAI-compatible: same path, same body, same audio-bytes response, same error envelope — an OpenAI SDK works by changing only base_url. Two fields behave differently on purpose; the table calls them out.
| Part | OpenAI | SaydiVoice |
|---|
| Path, auth, JSON body, audio bytes | POST /v1/audio/speech | Identical |
| model | tts-1, tts-1-hd, gpt-4o-mini-tts | tts-1 → 16 steps, tts-1-hd → 32 steps; any other id is accepted and uses the server default |
| voice | 13 built-in voices, or {id} for a custom voice | A SaydiVoice voice. The 13 OpenAI names are accepted but ALL resolve to one default (see the callout below); the {id} object form is rejected |
| instructions | Free-form style sentence on a fixed voice (gpt-4o-mini-tts) | Voice-design mode with a PRESET vocabulary (gender, age, pitch, accent, whisper). A free-form sentence is 400 with error.param "instructions"; so is combining it with a SaydiVoice voice |
| response_format | mp3, opus, aac, flac, wav, pcm | Same six; aac is served as mp3 |
| stream_format | audio (chunked), sse (audio events) | audio is accepted but the file arrives in one response; sse is rejected with 400 |
| Errors | {"error": {...}}, 400 for a bad field | Same envelope here; a bad field keeps status 422. Native endpoints keep {"detail": ...} |
| Name | Type | Required | Description |
|---|
input | string | yes | Text to speak. Schema maximum 4096 characters; your plan's per-request character cap also applies (Free: 3000), and exceeding it is a 429 with error.code "insufficient_quota". Use POST /tts for longer text. |
voice | string | yes | A SaydiVoice voice ID: vi-adam (or any other from GET /samples), or a cloned vc_... id from POST /v1/voices. OpenAI names are accepted but do not select a voice — they all fall back to the default. X-OV-Voice reports the voice that actually spoke. |
model | string | no | "tts-1" = 16 diffusion steps (faster), "tts-1-hd" = 32 steps (higher quality). Omitted = server default. |
response_format | string | no | mp3 (default), opus, aac, flac, wav, pcm. aac is currently served as mp3. |
speed | number | no | Speech speed, 0.25–4.0. Default 1.0. |
instructions | string | no | Voice-design mode, same engine as instruct on POST /tts. Vocabulary is PRESET — gender, age, pitch, accent, whisper — e.g. "female, british accent, whisper". A free-form style sentence (what gpt-4o-mini-tts accepts) is refused with 400, error.param "instructions". Cannot be combined with a SaydiVoice voice: design mode builds the speaker from the instruction. |
stream_format | string | no | Compatibility field. 'audio' is accepted and the whole file arrives in one response; 'sse' is rejected with 400 because audio events are not implemented. |
OpenAI voice names are accepted, but they do not select a voice — None of alloy/ash/ballad/coral/echo/fable/nova/onyx/sage/shimmer/verse/marin/cedar exist in this catalogue, so each resolves to one default voice (vi-adam unless OMNIVOICE_OPENAI_VOICE_DEFAULT says otherwise). The response marks it: X-OV-Voice-Source: openai-alias-default plus X-OV-Voice-Hint. Pass a SaydiVoice voice_id to choose a voice.
cURL
curl -sS -X POST "https://voice.saydi.ai/api/v1/audio/speech" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "tts-1-hd",
"voice": "vi-adam",
"input": "Higher quality, 32 diffusion steps.",
"response_format": "wav",
"speed": 1.0
}' \
--output speech.wav
Python
from openai import OpenAI
client = OpenAI(
api_key=os.environ["SAYDI_API_KEY"],
base_url="https://voice.saydi.ai/api/v1", # only this line changes
)
# The body is unchanged. `voice` is a SaydiVoice voice_id from GET /samples:
# OpenAI's built-in names are accepted but all resolve to one default voice.
with client.audio.speech.with_streaming_response.create(
model="tts-1",
voice="vi-adam",
input="Drop-in replacement.",
) as response:
response.stream_to_file("speech.mp3")
Node.js
import { writeFile } from "node:fs/promises";
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.SAYDI_API_KEY,
baseURL: "https://voice.saydi.ai/api/v1", // only this line changes
});
// The body is unchanged. `voice` is a SaydiVoice voice_id from GET /samples:
// OpenAI's built-in names are accepted but all resolve to one default voice.
const response = await client.audio.speech.create({
model: "tts-1",
voice: "vi-adam",
input: "Drop-in replacement.",
});
await writeFile("speech.mp3", Buffer.from(await response.arrayBuffer()));
Metering headers — Each audio response reports cost and timing: X-OV-Chars, X-Audio-Duration, X-Generation-Time, X-RTF.
| Header | Meaning |
|---|
| X-OV-Chars | Characters charged for this request |
| X-OV-Voice | Voice actually used (after fallback) |
| X-Audio-Duration | Generated audio length in seconds |
| X-Generation-Time | Compute time in seconds |
| X-RTF | Real-time factor: generation time / audio duration |
POST /tts
Full-control text to speech.
Use this endpoint for long text, fixed duration, auto pauses, voice design or sampling controls.
Voice selection — three modes
| Mode | Set this | Result |
|---|
| Voice cloning | sample (+ optional ref_text) | Speaks in a catalogue voice copied from its reference clip |
| Voice design | instruct | The model creates a voice matching a text description |
| Auto voice | neither | The model picks a voice for the text |
| Name | Type | Required | Description |
|---|
text | string | yes | Text to synthesize. |
sample | string | no | Voice_id from GET /samples (legacy name still accepted). Enables cloning mode. |
voice_id | string | no | A registered clone voice from POST /v1/voices (e.g. vc_9f2c...). No reference audio needed; the server uses the stored clip and transcript. |
instruct | string | no | Voice design description, e.g. "female, low pitch, british accent". Ignored when sample is set. |
ref_text | string | no | Override the reference transcript for the sample. Improves cloning accuracy. |
lang | string | no | Language hint (ISO 639-1). Used for text normalization and pronunciation. |
breaks | object | no | Auto pauses in ms per punctuation type: {"sentence":450,"comma":250,"semicolon":300,"paragraph":600}. |
duration | number | no | Fixed output duration in seconds (max 600). Overrides speed. Useful to fit a time slot. |
speed | number | no | Speed factor, 0–10. Ignored when duration is set. |
num_step | integer | no | Diffusion steps 1–256. Default 32; 16 is faster. |
guidance_scale | number | no | Classifier-free guidance, 0–50. Default 2.0. |
t_shift | number | no | Noise-schedule shift, 0–1. Default 0.1. |
denoise | boolean | no | Prepend the denoising token. Default true. |
preprocess_prompt | boolean | no | Clean the reference clip before cloning. Default true. |
postprocess_output | boolean | no | Trim long silences from the output. Default true. |
audio_chunk_duration | number | no | Target chunk length in seconds for long text (max 120). Default 15. |
audio_chunk_threshold | number | no | Estimated duration that triggers chunking (max 600). Default 30. |
output_format | string | no | wav, mp3, flac or ogg. Default from server config. |
Long text with pauses and a fixed duration
curl -sS -X POST "https://voice.saydi.ai/api/tts" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Câu thứ nhất. Câu thứ hai, có dấu phẩy.",
"sample": "vi-adam",
"lang": "vi",
"breaks": {"sentence": 450, "comma": 250},
"num_step": 32,
"output_format": "mp3"
}' \
--output long.mp3
Voice design (no reference clip)
curl -sS -X POST "https://voice.saydi.ai/api/tts" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "A calm narrator for a documentary.",
"instruct": "male, middle-aged, low pitch, american accent",
"lang": "en"
}' \
--output designed.mp3
POST /tts/file
Generate speech from a .txt file.
Send the script as a UTF-8 .txt file instead of embedding a long string in JSON.
cURL
curl -sS -X POST "https://voice.saydi.ai/api/tts/file" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-F "text_file=@script.txt" \
-F "sample=vi-adam" \
-F "lang=vi" \
-F "output_format=mp3" \
--output script.mp3
Accepts every field of POST /tts; use text_file instead of text.
GET /samples
List voices and metadata.
Pass voice_id as voice (OpenAI-compatible) or sample (native). Legacy name still works; use display_name for UI labels.
| Name | Type | Required | Description |
|---|
lang | string | no | Return only voices whose primary language matches, e.g. "vi". |
limit | integer | no | Return at most this many voices, after sorting by usage. |
cURL
curl -sS "https://voice.saydi.ai/api/samples?lang=vi&limit=5" \
-H "Authorization: Bearer $SAYDI_API_KEY"
Python
import os, requests
r = requests.get(
"https://voice.saydi.ai/api/samples?lang=vi&limit=5",
headers={"Authorization": f"Bearer {os.environ['SAYDI_API_KEY']}"},
timeout=30,
)
r.raise_for_status()
for v in r.json():
print(v["voice_id"], v["display_name"], sep="\t")
Node.js
const res = await fetch(
"https://voice.saydi.ai/api/samples?lang=vi&limit=5",
{ headers: { Authorization: `Bearer ${process.env.SAYDI_API_KEY}` } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
for (const v of await res.json()) {
console.log([v.voice_id, v.display_name].join("\t"));
}
200 — An array of voice objects.
[
{
"name": "adam-11labs-vi",
"voice_id": "vi-adam",
"audio_file": "adam-11labs-vi.wav",
"has_transcript": true,
"transcript_preview": "Xin chào, tôi là Adam...",
"display_name": "Adam — Giọng hot tiktok",
"gender": "male",
"age": "young",
"accent": "vietnamese",
"locale": "vi-VN",
"language": "vi",
"languages": ["vi"],
"use_case": "conversational",
"category": "high_quality",
"featured": true,
"usage_count": 12840
}
]
GET /samples/{name}/audio — Preview a voice
Streams the reference clip for a voice, so you can audition it before generating.
cURL
curl -sS "https://voice.saydi.ai/api/samples/vi-adam/audio" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
--output preview.wav
Python
import os, requests
r = requests.get(
"https://voice.saydi.ai/api/samples/vi-adam/audio",
headers={"Authorization": f"Bearer {os.environ['SAYDI_API_KEY']}"},
timeout=30,
)
r.raise_for_status()
open("preview.wav", "wb").write(r.content)
Node.js
import { writeFile } from "node:fs/promises";
const res = await fetch("https://voice.saydi.ai/api/samples/vi-adam/audio", {
headers: { Authorization: `Bearer ${process.env.SAYDI_API_KEY}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("preview.wav", Buffer.from(await res.arrayBuffer()));
GET /v1/models — List models
OpenAI-compatible model list: tts-1 (16 steps) and tts-1-hd (32 steps).
cURL
curl -sS "https://voice.saydi.ai/api/v1/models" \
-H "Authorization: Bearer $SAYDI_API_KEY"
Python
import os, requests
r = requests.get(
"https://voice.saydi.ai/api/v1/models",
headers={"Authorization": f"Bearer {os.environ['SAYDI_API_KEY']}"},
timeout=30,
)
r.raise_for_status()
for m in r.json()["data"]:
print(m["id"])
Node.js
const res = await fetch("https://voice.saydi.ai/api/v1/models", {
headers: { Authorization: `Bearer ${process.env.SAYDI_API_KEY}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
for (const m of (await res.json()).data) {
console.log(m.id);
}
Clone a voice, then call it
For developers: upload a recording, get a voice code, then read with it.
Clone once, get a voice ID, then use that ID like any other voice. Nothing to re-upload on later requests.
- Prepare a reference clip — 10–15 seconds of one person speaking. Clean audio, no music, no second speaker.
- Register it — POST /v1/voices — Multipart upload (
file, name, language, consent). The response contains id and status. - Wait for
ready — ready means usable now — the normal case. processing means background separation is running: poll GET /v1/voices/{voice_id} (usually seconds). - Speak with the ID — POST /v1/audio/speech with
voice set to the vc_... id. This is the same call as Call a voice.
cURL
API="https://voice.saydi.ai/api"
AUTH="Authorization: Bearer $SAYDI_API_KEY"
# 1. Register the clip once. Returns a stable voice id.
resp=$(curl -sS -X POST "$API/v1/voices" \
-H "$AUTH" \
-H "Idempotency-Key: my-voice-v1" \
-F "file=@my-voice.m4a;type=audio/mp4" \
-F "name=My voice" \
-F "language=vi" \
-F "consent=true")
voice_id=$(echo "$resp" | jq -r '.id')
status=$(echo "$resp" | jq -r '.status')
echo "voice_id: $voice_id status: $status"
# 2. If separation is running, wait for ready (usually seconds).
while [ "$status" = "processing" ]; do
sleep 2
status=$(curl -sS "$API/v1/voices/$voice_id" -H "$AUTH" | jq -r '.status')
done
# 3. Speak with it. No clip to upload.
curl -sS -X POST "$API/v1/audio/speech" \
-H "$AUTH" \
-H "Content-Type: application/json" \
-d "{\"model\":\"tts-1\",\"voice\":\"$voice_id\",\"input\":\"Xin chào từ giọng của tôi.\"}" \
--output hello.mp3
Python
import os, time, requests
key = os.environ["SAYDI_API_KEY"]
base = "https://voice.saydi.ai/api"
h = {"Authorization": f"Bearer {key}"}
# 1. Register the clip once. Returns a stable voice id.
with open("my-voice.m4a", "rb") as fh:
r = requests.post(
f"{base}/v1/voices",
files={"file": ("my-voice.m4a", fh, "audio/mp4")},
data={"name": "My voice", "language": "vi", "consent": "true"},
headers={**h, "Idempotency-Key": "my-voice-v1"}, # retry-safe
timeout=300,
)
r.raise_for_status()
voice = r.json()
voice_id = voice["id"] # e.g. vc_9f2c1a4b7e0d4a1c8b3f5e6d7a9c0b12
print("voice_id:", voice_id, "status:", voice["status"])
# 2. If separation is running, wait for ready (usually seconds).
for _ in range(30):
if voice["status"] != "processing":
break
time.sleep(2)
voice = requests.get(f"{base}/v1/voices/{voice_id}", headers=h, timeout=60).json()
# 3. Speak with it. No clip to upload.
audio = requests.post(
f"{base}/v1/audio/speech", headers=h,
json={"model": "tts-1", "voice": voice_id, "input": "Xin chào từ giọng của tôi."},
timeout=180,
)
audio.raise_for_status()
open("hello.mp3", "wb").write(audio.content)
Node.js
import { readFile, writeFile } from "node:fs/promises";
const API = "https://voice.saydi.ai/api";
const H = { Authorization: `Bearer ${process.env.SAYDI_API_KEY}` };
// 1. Register the clip once. Returns a stable voice id.
const form = new FormData();
form.append("file", new Blob([await readFile("my-voice.m4a")], { type: "audio/mp4" }), "my-voice.m4a");
form.append("name", "My voice");
form.append("language", "vi");
form.append("consent", "true");
let res = await fetch(`${API}/v1/voices`, {
method: "POST",
headers: { ...H, "Idempotency-Key": "my-voice-v1" }, // retry-safe
body: form,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
let voice = await res.json();
const voiceId = voice.id; // e.g. vc_9f2c1a4b7e0d4a1c8b3f5e6d7a9c0b12
console.log("voice_id:", voiceId, "status:", voice.status);
// 2. If separation is running, wait for ready (usually seconds).
while (voice.status === "processing") {
await new Promise((r) => setTimeout(r, 2000));
res = await fetch(`${API}/v1/voices/${voiceId}`, { headers: H });
voice = await res.json();
}
// 3. Speak with it. No clip to upload.
res = await fetch(`${API}/v1/audio/speech`, {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({ model: "tts-1", voice: voiceId, input: "Xin chào từ giọng của tôi." }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("hello.mp3", Buffer.from(await res.arrayBuffer()));
What makes a usable reference clip
- 10–15 seconds of continuous speech from one person — the sweet spot for similarity.
- No background music, no second speaker, no heavy room reverb.
- WAV, MP3, M4A, OGG, FLAC or WebM; up to 5 minutes — the server keeps the best stretch.
- Send
ref_text (the exact transcript) when you have it; omit it and the clip is transcribed for you. POST /voice/transcribe returns one.
Consent and clone slots — consent=true is mandatory and only voices you have the right to use may be registered. Each plan includes clone slots; going over returns 402 with code: voice_limit_reached. A failed registration does not consume a slot.
One-off alternative — POST /tts/clone clones and speaks in a single multipart request. Good for a one-off; re-uploading the clip on every call is slower, so use a registered voice for anything repeated.
POST /v1/voices
Register a voice once, reuse it by voice_id.
Upload a clip and a name; get back a stable voice_id. SaydiVoice checks the audio, keeps the best continuous stretch of speech, transcribes it and removes background music if it finds any. The reference is cached server-side, so later generations start faster and no clip is re-uploaded. These voices also appear in Studio → My Voices, and Studio clones appear here. For the full walkthrough see Clone a voice, then call it.
POST /v1/voices — Register a voice
Multipart upload. Returns the voice object; status is ready when it is usable right away.
| Name | Type | Required | Description |
|---|
file | file | yes | Reference clip: WAV, MP3, M4A, OGG, FLAC or WebM. Any length up to 5 minutes; the best stretch is selected for you. |
name | string | yes | Display name. Not unique — identify the voice by id, never by name. |
consent | string | yes | Confirm you have the right to clone this voice. Send true, or JSON {"accepted": true}. Recorded with your IP and the terms version. |
language | string | no | ISO-639-1 hint (e.g. vi). Also picks the language of the preview sentence. |
ref_text | string | no | Transcript of the clip. Omit it and we transcribe for you; send it (when your clip is already prepared) to skip that step. |
separate | string | no | auto (default) separates background music only when detected; true always does; false never does. |
Idempotency-Key | string | no | Header. Retrying with the same value returns the voice that already exists instead of creating a second one — so a timeout never costs you a clone slot. |
cURL
curl -sS -X POST "https://voice.saydi.ai/api/v1/voices" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-F "file=@my-recording.m4a" \
-F "name=Giọng của tôi" \
-F "language=vi" \
-F "consent=true"
# -> {"id":"vc_9f2c...","status":"ready","ref_text":"...","quality":{...}}
# Then speak with it — no clip to upload:
curl -sS -X POST "https://voice.saydi.ai/api/v1/audio/speech" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","voice":"vc_9f2c...","input":"Xin chào từ giọng của tôi."}' \
--output hello.mp3
Python
import os, requests
key = os.environ["SAYDI_API_KEY"]
base = "https://voice.saydi.ai/api"
with open("my-recording.m4a", "rb") as fh:
voice = requests.post(
f"{base}/v1/voices",
headers={"Authorization": f"Bearer {key}"},
files={"file": ("my-recording.m4a", fh, "audio/mp4")},
data={"name": "Giọng của tôi", "language": "vi", "consent": "true"},
timeout=300,
)
voice.raise_for_status()
voice = voice.json()
print(voice["id"], voice["status"], voice.get("quality", {}).get("warnings"))
audio = requests.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}"},
json={"model": "tts-1", "voice": voice["id"], "input": "Xin chào từ giọng của tôi."},
timeout=180,
)
audio.raise_for_status()
open("hello.mp3", "wb").write(audio.content)
Node.js
import { readFile, writeFile } from "node:fs/promises";
const key = process.env.SAYDI_API_KEY;
const base = "https://voice.saydi.ai/api";
const form = new FormData();
form.append("file", new Blob([await readFile("my-recording.m4a")], { type: "audio/mp4" }), "my-recording.m4a");
form.append("name", "Giọng của tôi");
form.append("language", "vi");
form.append("consent", "true");
let voice = await fetch(`${base}/v1/voices`, {
method: "POST",
headers: { Authorization: `Bearer ${key}` },
body: form,
});
if (!voice.ok) throw new Error(`HTTP ${voice.status}: ${await voice.text()}`);
voice = await voice.json();
console.log(voice.id, voice.status, voice.quality?.warnings);
const audio = await fetch(`${base}/v1/audio/speech`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "tts-1", voice: voice.id, input: "Xin chào từ giọng của tôi." }),
});
if (!audio.ok) throw new Error(`HTTP ${audio.status}: ${await audio.text()}`);
await writeFile("hello.mp3", Buffer.from(await audio.arrayBuffer()));
201 — The voice was created.
{
"id": "vc_9f2c1a4b7e0d4a1c8b3f5e6d7a9c0b12",
"object": "voice",
"name": "Giọng của tôi",
"status": "ready",
"language": "vi",
"ref_seconds": 7.4,
"ref_text": "Xin chào, đây là đoạn nói trong clip mẫu.",
"quality": {
"speech_seconds": 6.9,
"snr_db": 26.4,
"trimmed_seconds": 42.1,
"music_detected": false,
"separated": false,
"warnings": []
},
"consent": {"terms_version": "voice-terms-2026-01"},
"preview_url": "/v1/voices/vc_9f2c1a4b7e0d4a1c8b3f5e6d7a9c0b12/preview"
}
200 — An idempotent retry: this voice already existed and is returned unchanged.
{
"id": "vc_9f2c1a4b7e0d4a1c8b3f5e6d7a9c0b12",
"status": "ready",
"idempotent_replay": true
}
processing vs ready — ready means usable now — the normal case. processing means background separation is still running: poll GET /v1/voices/{voice_id} (usually seconds) before synthesising. A failed voice frees its slot and carries an error.code. Two worth handling: 503 storage_unavailable is temporary — retry with backoff; 410 ref_missing means the stored audio is gone — register the voice again (no extra slot).
GET /v1/voices — List and manage voices
List your voices, fetch one, rename it, delete it, or hear a preview.
| Endpoint | What it does |
|---|
GET /v1/voices | This account's voices, newest first, with usage (slots used/limit). Filter with ?status=ready. |
GET /v1/voices/{voice_id} | One voice: status, ref_text and the quality report. |
PATCH /v1/voices/{voice_id} | Rename: JSON {"name": "..."}. The id never changes. |
DELETE /v1/voices/{voice_id} | Delete the voice and its stored audio, freeing its clone slot. Immediate. |
GET /v1/voices/{voice_id}/preview | MP3 of a short sentence in that voice. Rendered once and cached. Not charged against your characters. |
cURL
curl -sS "https://voice.saydi.ai/api/v1/voices" \
-H "Authorization: Bearer $SAYDI_API_KEY"
curl -sS -X PATCH "https://voice.saydi.ai/api/v1/voices/$VOICE_ID" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Giọng nam trầm"}'
curl -sS "https://voice.saydi.ai/api/v1/voices/$VOICE_ID/preview" \
-H "Authorization: Bearer $SAYDI_API_KEY" --output preview.mp3
curl -sS -X DELETE "https://voice.saydi.ai/api/v1/voices/$VOICE_ID" \
-H "Authorization: Bearer $SAYDI_API_KEY"
Python
import os, requests
key = os.environ["SAYDI_API_KEY"]
base = "https://voice.saydi.ai/api"
h = {"Authorization": f"Bearer {key}"}
voice_id = "vc_9f2c..." # the id to manage
# List every registered voice.
print(requests.get(f"{base}/v1/voices", headers=h, timeout=30).json())
# Rename it.
r = requests.patch(
f"{base}/v1/voices/{voice_id}",
headers=h, json={"name": "Giọng nam trầm"}, timeout=30,
)
r.raise_for_status()
# Save a preview clip.
audio = requests.get(f"{base}/v1/voices/{voice_id}/preview", headers=h, timeout=60)
audio.raise_for_status()
open("preview.mp3", "wb").write(audio.content)
# Delete it.
r = requests.delete(f"{base}/v1/voices/{voice_id}", headers=h, timeout=30)
r.raise_for_status()
Node.js
import { writeFile } from "node:fs/promises";
const key = process.env.SAYDI_API_KEY;
const base = "https://voice.saydi.ai/api";
const h = { Authorization: `Bearer ${key}` };
const voiceId = "vc_9f2c..."; // the id to manage
// List every registered voice.
console.log(await (await fetch(`${base}/v1/voices`, { headers: h })).json());
// Rename it.
let res = await fetch(`${base}/v1/voices/${voiceId}`, {
method: "PATCH",
headers: { ...h, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Giọng nam trầm" }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
// Save a preview clip.
res = await fetch(`${base}/v1/voices/${voiceId}/preview`, { headers: h });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("preview.mp3", Buffer.from(await res.arrayBuffer()));
// Delete it.
res = await fetch(`${base}/v1/voices/${voiceId}`, { method: "DELETE", headers: h });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
Consent and slot limits — Only register voices you have the right to use; the API refuses a registration without consent. Each plan includes a number of clone slots (clone_slots) — creating a voice beyond it returns 402 with code: voice_limit_reached. A failed registration does not consume a slot.
POST /tts/clone
Clone and speak in one request.
Clone and speak in one multipart request. Ten to fifteen seconds of clean, single-speaker audio works best.
| Name | Type | Required | Description |
|---|
text | string | yes | Text to speak in the cloned voice. |
ref_audio | file | yes | Reference clip. Mono WAV recommended; 10–15 s of clean single-speaker audio works best. Max 10 MB. |
ref_text | string | no | Transcript of the clip. Strongly recommended; auto-transcribed when omitted. |
lang | string | no | Language hint for normalization and transcription. |
duration | number | no | Fixed output duration in seconds. Overrides speed. |
speed | number | no | Speed factor. |
guidance_scale | number | no | Classifier-free guidance scale. |
num_step | integer | no | Diffusion steps. |
breaks | string | no | JSON string of auto-pause config, e.g. {"sentence":450,"comma":250}. |
output_format | string | no | wav, mp3, flac or ogg. |
cURL
curl -sS -X POST "https://voice.saydi.ai/api/tts/clone" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-F "ref_audio=@reference.wav;type=audio/wav" \
-F "ref_text=Đây là câu nói trong clip mẫu." \
-F "text=Xin chào, đây là giọng đã nhân bản." \
-F "output_format=mp3" \
--output cloned.mp3
Python
import os, requests
with open("reference.wav", "rb") as fh:
r = requests.post(
"https://voice.saydi.ai/api/tts/clone",
headers={"Authorization": f"Bearer {os.environ['SAYDI_API_KEY']}"},
data={
"text": "Xin chào, đây là giọng đã nhân bản.",
"ref_text": "Đây là câu nói trong clip mẫu.",
"output_format": "mp3",
},
files={"ref_audio": ("reference.wav", fh, "audio/wav")},
timeout=180,
)
r.raise_for_status()
open("cloned.mp3", "wb").write(r.content)
Node.js
import { readFile, writeFile } from "node:fs/promises";
const form = new FormData();
form.append("ref_audio", new Blob([await readFile("reference.wav")], { type: "audio/wav" }), "reference.wav");
form.append("ref_text", "Đây là câu nói trong clip mẫu.");
form.append("text", "Xin chào, đây là giọng đã nhân bản.");
form.append("output_format", "mp3");
const res = await fetch("https://voice.saydi.ai/api/tts/clone", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SAYDI_API_KEY}` },
body: form,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("cloned.mp3", Buffer.from(await res.arrayBuffer()));
Constraints — Reference audio max 10 MB. Only clone voices you have permission to use; re-uploading the clip every call is slower than a saved voice.
POST /voice/transcribe
Transcribe a reference clip for better cloning.
Transcribe a short reference clip to get ref_text for better cloning accuracy.
| Name | Type | Required | Description |
|---|
audio | file | yes | The reference clip. Mono WAV recommended, max 10 MB. |
lang | string | no | Optional language hint (ISO 639-1). |
cURL
curl -sS -X POST "https://voice.saydi.ai/api/voice/transcribe" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-F "audio=@reference.wav" \
-F "lang=vi"
Python
import os, requests
with open("reference.wav", "rb") as fh:
r = requests.post(
"https://voice.saydi.ai/api/voice/transcribe",
headers={"Authorization": f"Bearer {os.environ['SAYDI_API_KEY']}"},
files={"audio": ("reference.wav", fh, "audio/wav")},
data={"lang": "vi"},
timeout=300,
)
r.raise_for_status()
print(r.json())
Node.js
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("audio", new Blob([await readFile("reference.wav")], { type: "audio/wav" }), "reference.wav");
form.append("lang", "vi");
const res = await fetch("https://voice.saydi.ai/api/voice/transcribe", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SAYDI_API_KEY}` },
body: form,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());
200 — Transcription plus a quality verdict. Check ok, not just transcript.
{
"ok": true,
"reason": "ok",
"transcript": "Đây là câu nói trong clip mẫu.",
"word_count": 8,
"char_count": 25
}
Credits & quota
How text and cloning are billed.
Text to speech and cloning are billed by character from one shared quota for studio and API.
| Event | Meter | Charged |
|---|
| POST /v1/audio/speech | characters | input length |
| POST /tts, /tts/file | characters | text length |
| POST /tts/clone | characters | text length |
| POST /voice/transcribe | feature gate | No |
| GET /samples, /v1/models | none | No |
cURL
# Current subscription (plan, cycle, period end)
curl -sS "https://voice.saydi.ai/api/billing/me" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN"
# Would this text fit? Cheap check before spending GPU time.
curl -sS "https://voice.saydi.ai/api/billing/quota/preflight?chars=1200" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN"
# Everything the developer console shows, in one call (see Usage API below):
curl -sS "https://voice.saydi.ai/api/usage/summary?days=30" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN"
Python
import os, requests
h = {"Authorization": f"Bearer {os.environ['SAYDI_ACCESS_TOKEN']}"}
# Current subscription (plan, cycle, period end)
plan = requests.get("https://voice.saydi.ai/api/billing/me", headers=h, timeout=30)
plan.raise_for_status()
print(plan.json())
# Would this text fit? Cheap check before spending GPU time.
q = requests.get("https://voice.saydi.ai/api/billing/quota/preflight?chars=1200", headers=h, timeout=30)
q.raise_for_status()
print(q.json())
# Everything the developer console shows, in one call (see Usage API below):
s = requests.get("https://voice.saydi.ai/api/usage/summary?days=30", headers=h, timeout=30)
s.raise_for_status()
print(s.json())
Node.js
const H = { Authorization: `Bearer ${process.env.SAYDI_ACCESS_TOKEN}` };
// Current subscription (plan, cycle, period end)
let res = await fetch("https://voice.saydi.ai/api/billing/me", { headers: H });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());
// Would this text fit? Cheap check before spending GPU time.
res = await fetch("https://voice.saydi.ai/api/billing/quota/preflight?chars=1200", { headers: H });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());
// Everything the developer console shows, in one call (see Usage API below):
res = await fetch("https://voice.saydi.ai/api/usage/summary?days=30", { headers: H });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());
Usage API
Query balances, usage and call history.
Requires a signed-in user token. Use these endpoints for your own dashboards and alerts.
GET /usage/summary — Credits and request counters
Credit balance (shared with the studio) plus API request counts and a per-day series.
| Name | Type | Required | Description |
|---|
days | integer | no | Series window, 1–365. Default 30. |
cURL
curl -sS "https://voice.saydi.ai/api/usage/summary?days=30" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN"
Python
import os, requests
r = requests.get(
"https://voice.saydi.ai/api/usage/summary?days=30",
headers={"Authorization": f"Bearer {os.environ['SAYDI_ACCESS_TOKEN']}"},
timeout=30,
)
r.raise_for_status()
print(r.json())
Node.js
const res = await fetch("https://voice.saydi.ai/api/usage/summary?days=30", {
headers: { Authorization: `Bearer ${process.env.SAYDI_ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());
200 — Credits come from the shared pool; requests/characters count API traffic only.
{
"ok": true,
"plan_code": "pro",
"period_start": 1758844800.0,
"period_end": 1761436800.0,
"credits": {
"status": "ready",
"unlimited": false,
"limit": 600000,
"used": 41250,
"remaining": 558750,
"extra": 0,
"day_limit": null,
"day_used": 3200,
"day_remaining": null
},
"api": {
"limits": {"api_concurrency": 5, "clone_slots": 50, "max_chars_request": 100000},
"requests": {"total": 412, "success": 401, "errors": 11},
"characters": 41250,
"last_call_at": 1760001234.5,
"status": "ready",
"days": [
{"day": "2026-09-25", "requests": 18, "characters": 2100, "errors": 0},
{"day": "2026-09-26", "requests": 24, "characters": 3200, "errors": 2}
]
}
}
GET /usage/calls — Call history
Newest calls first, including rejected ones, with a cursor for paging.
| Name | Type | Required | Description |
|---|
limit | integer | no | Page size, 1–200. Default 50. |
before | number | no | Cursor: the next_before value from the previous page. |
cURL
curl -sS "https://voice.saydi.ai/api/usage/calls?limit=25" \
-H "Authorization: Bearer $SAYDI_ACCESS_TOKEN"
Python
import os, requests
r = requests.get(
"https://voice.saydi.ai/api/usage/calls?limit=25",
headers={"Authorization": f"Bearer {os.environ['SAYDI_ACCESS_TOKEN']}"},
timeout=30,
)
r.raise_for_status()
print(r.json())
Node.js
const res = await fetch("https://voice.saydi.ai/api/usage/calls?limit=25", {
headers: { Authorization: `Bearer ${process.env.SAYDI_ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());
200 — Rows are retained for 400 days. next_before is null on the last page.
{
"ok": true,
"status": "ready",
"next_before": 1760001000.0,
"calls": [
{
"ts": 1760001234.5,
"method": "POST",
"endpoint": "/v1/audio/speech",
"status": 200,
"chars": 41,
"voice": "vi-adam",
"latency_ms": 3820,
"key_id": "key_1f0c9a2b7d3e4f56",
"limit_reason": ""
},
{
"ts": 1760001000.0,
"method": "POST",
"endpoint": "/tts",
"status": 429,
"chars": 0,
"voice": "",
"latency_ms": 6,
"key_id": "key_1f0c9a2b7d3e4f56",
"limit_reason": "api-key-window"
}
]
}
Rate limits & concurrency
Per-key request and concurrency limits.
Limits are applied per API key; concurrency is per plan.
| Limit | Default | Response when exceeded |
|---|
| Requests per key | 60 / minute | 429, Retry-After, X-OV-Limit: api-key-window |
| Concurrent generations | Starter 2 · Creator 3 · Pro 5 · Custom 10 | 429, Retry-After, X-OV-Limit: api-concurrency |
cURL
TEXT="Xin chào, đây là API của SaydiVoice."
attempt=0
while :; do
code=$(curl -sS -o speech.mp3 -w "%{http_code}" -X POST "https://voice.saydi.ai/api/v1/audio/speech" \
-H "Authorization: Bearer $SAYDI_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"tts-1\",\"voice\":\"vi-adam\",\"input\":\"$TEXT\"}")
[ "$code" != "429" ] && break
attempt=$((attempt + 1))
[ "$attempt" -ge 4 ] && { echo "rate limited after retries" >&2; exit 1; }
sleep $((5 * 2 ** attempt)) # exponential backoff
done
Python
import time, requests
def speak(text, voice, tries=4):
for attempt in range(tries):
r = requests.post(
"https://voice.saydi.ai/api/v1/audio/speech",
headers={"Authorization": f"Bearer {KEY}"},
json={"input": text, "voice": voice},
timeout=180,
)
if r.status_code != 429:
r.raise_for_status()
return r.content
wait = int(r.headers.get("Retry-After", "5"))
time.sleep(max(1, wait) * (2 ** attempt)) # exponential backoff
raise RuntimeError("rate limited after retries")
Node.js
const speak = async (text, voice, tries = 4) => {
for (let attempt = 0; attempt < tries; attempt++) {
const res = await fetch("https://voice.saydi.ai/api/v1/audio/speech", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ input: text, voice }),
});
if (res.status !== 429) {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return Buffer.from(await res.arrayBuffer());
}
const wait = Number(res.headers.get("Retry-After") || "5");
await new Promise((r) => setTimeout(r, Math.max(1, wait) * 2 ** attempt * 1000)); // exponential backoff
}
throw new Error("rate limited after retries");
};
On 429 — Wait for Retry-After, then retry with backoff and jitter.
Error codes
Error shapes, common codes and retry guidance.
Native endpoints return {"detail": ...}. POST /v1/audio/speech and GET /v1/models return the OpenAI {"error": {...}} shape for EVERY failure — bad credentials (401), a bad field (422), a quota or rate limit (429), a server error (500) — with the response headers (Retry-After, X-OV-Limit) preserved. One deliberate difference: a bad field keeps status 422 here, while OpenAI answers 400 for the same mistake.
Native
{
"detail": "Field 'text' must not be empty."
}
OpenAI-compatible
{
"error": {
"message": "Field 'input' must not be empty.",
"type": "invalid_request_error",
"param": "input",
"code": "invalid_value"
}
}
| Status | Meaning | Retry? |
|---|
| 400 | Malformed request, empty text, or undecodable reference audio | No — fix the request |
| 401 | Missing, unknown, revoked or expired credential | No — fix the credential |
| 403 | Account or token blocked for suspected abuse | No — contact support |
| 404 | Endpoint or voice not found | No |
| 413 | Text too long, or upload above 10 MB | No — split or compress |
| 422 | Request failed schema validation (wrong type or range) | No — check parameter types |
| 429 | Quota exhausted, per-key rate limit, or concurrency cap (on /v1/*, error.code separates them: insufficient_quota vs rate_limit_exceeded) | Yes — after Retry-After |
| 500 | Unexpected server error | Yes — with backoff |
| 503 | Backend temporarily unavailable or not configured | Yes — with backoff |
X-OV-Limit on 403/429 names the limit that blocked the request.
| X-OV-Limit | Status | Cause |
|---|
| api-key-window | 429 | Per-key request rate exceeded |
| api-concurrency | 429 | Too many in-flight generations for the plan |
| usage-limit | 429 / 403 | Credit quota exhausted, or account soft-limited |
| blocked | 403 | Account or token blocked |
| ip-window | 429 | Edge per-IP limit (shared infrastructure, not your key) |
| concurrency | 503 | Server-wide in-flight ceiling reached |
Plain-language glossary
The technical words on this site, explained in plain language.
Hit a word you do not know? Look it up here. You do not need to memorise anything — this page is only here to make the rest easier to read.
| Word | What it means |
|---|
| API | The way two programs talk to each other. Here: how your system asks SaydiVoice to read text out loud. |
| Access key | A secret code starting with sv_live_. It tells SaydiVoice it is you. Treat it like a password and never share it. |
| Voice ID / voice code | A short code for one voice, for example vi-adam. |
| Endpoint | One specific address that does one job. For example /v1/audio/speech is the address that reads text out loud. |
| Request / response | The request is what you send; the response is what comes back. |
| Header | Extra information sent along with every request, such as your access key. |
| Bearer | How the key is written in the header: Authorization: Bearer sv_live_.... |
| JSON | A text format for structured data. SaydiVoice returns JSON when something went wrong. |
| Audio bytes | The content of a sound file. Saved as an .mp3 it plays normally. |
| Multipart | A way of sending a file (for example a voice recording) together with a request. |
| 429 / rate limit | You are sending requests too fast. Wait a moment and try again. |
| Consent | Your confirmation that you have the right to clone that voice. |
| Clone | Making a voice that sounds like the recording you uploaded. |
Still stuck? — Email contact@saydi.ai and describe what you are trying to do in your own words — no technical explanation needed.
Changelog
Recent API changes and versioning.
| Date | Version | Change | Breaking? |
|---|
| 2026-09-26 | 1.0 | Giai đoạn 1: per-account API keys, per-key rate limit, per-plan concurrency, shared character credits. | — |
Versioning policy — Additive changes ship without notice. Breaking changes get a new /vN prefix and an entry here.
Support
Where to get help.
| Topic | Channel |
|---|
| Integration questions | contact@saydi.ai |
| Higher limits / Enterprise | contact@saydi.ai |
| Suspected abuse or a leaked key | Revoke the key in the dashboard, then contact support |
| Service status | GET /health (no auth required) |