Listing TTS voices and getting an MP3 back, and why voice ids don't travel
One GET lists every servable voice, one POST returns audio bytes — but a voice id is pinned to one model, and the container you ask for isn't always what arrives.
Two calls. GET /v1/ai/tts/voices returns the catalogue of voices Infrai can currently serve, and POST /v1/audio/speech returns raw audio bytes you redirect into a file. On the second question — no, voice ids are not universal. Each one is tagged with the vendor and the specific model it works with, and crossing those wires returns a 400 rather than a substitute voice.
That’s stricter than the OpenAI-compatible shape suggests, and it’s deliberate: a voice is a vendor asset, not a portable identifier. Below is the catalogue we read on 2026-07-27, the exact mismatch errors, and the container gotcha that will bite anyone who trusts response_format blindly.
Read the catalogue first
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/ai/tts/voices" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "vendor": "alibaba_intl", "voice_id": "Cherry", "is_default": true, "name": "Cherry", "language": "zh-CN", "languages": ["Chinese (Mandarin)", "English", "French", "German", "Japanese", "Korean"], "model": "qwen3-tts-flash" },
{ "vendor": "alibaba_intl", "voice_id": "Ethan", "is_default": false, "name": "Ethan", "language": "zh-CN", "languages": ["Chinese (Mandarin)", "English"], "model": "qwen3-tts-flash" },
{ "vendor": "tencent_tts", "voice_id": "502007", "is_default": false, "name": "智小虎", "languages": ["Chinese", "English"], "model": "1" },
{ "vendor": "elevenlabs", "voice_id": "21m00Tcm4TlvDq8ikWAM", "is_default": true, "name": "Rachel", "model": "eleven_multilingual_v2" }
],
"next_cursor": null,
"count": 97
}
}
Ninety-seven voices across three vendors that day — 48 from alibaba_intl, 48 from tencent_tts and a single ElevenLabs voice. Look at the shape of the ids and the answer to the portability question is already visible: Ethan, 502007 and 21m00Tcm4TlvDq8ikWAM are three vendors’ naming schemes, not three entries in one namespace.
Filter when you only care about one vendor.
curl -sS "https://api.infrai.cc/v1/ai/tts/voices?vendor=elevenlabs" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The read is free and rate-limited only, so cache it for an hour rather than shipping a hardcoded voice list that goes stale when a vendor key rotates.
Generate the audio
Pass the voice’s own model straight through from the catalogue entry. That single rule prevents almost every error in this API.
curl -sS -X POST "https://api.infrai.cc/v1/audio/speech" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"vendor": "elevenlabs",
"model": "eleven_multilingual_v2",
"voice": "21m00Tcm4TlvDq8ikWAM",
"input": "Your invoice for July is ready to download.",
"response_format": "mp3"
}' \
--output invoice-notice.mp3
file invoice-notice.mp3
That returns Content-Type: audio/mpeg and a file starting with an ID3 tag — a genuine MP3, about 44 KB for that sentence. Omit vendor and voice entirely and you get the platform default voice instead, which is the right call when you don’t care who speaks.
The mismatch, in full
Here’s the answer to the portability question as the API states it. Ask alibaba_intl to use the ElevenLabs voice:
{
"error": {
"message": "voice '21m00Tcm4TlvDq8ikWAM' does not belong to vendor 'alibaba_intl' (known vendor(s): elevenlabs)",
"type": "invalid_request_error",
"code": "VOICE_MODEL_MISMATCH",
"param": null
}
}
The binding is tighter than vendor level. Ethan is an alibaba_intl voice pinned to qwen3-tts-flash, so naming any other model for the same vendor is rejected too — and helpfully, the message hands you the model the voice does belong to:
{
"error": {
"message": "voice 'Ethan' (vendor 'alibaba_intl') is not compatible with model 'qwen-tts' (compatible model(s): qwen3-tts-flash)",
"type": "invalid_request_error",
"code": "VOICE_MODEL_MISMATCH",
"param": null
}
}
That compatible model(s) list is the repair instruction: copy it into your request and the call goes through. It’s also why hardcoding a vendor’s model string is a worse idea than reading model off the catalogue entry — when a vendor retires a model, the catalogue moves its voices and your code follows automatically.
So the mental model is: a voice belongs to one model, that model belongs to one vendor, and the catalogue tells you both. One more consequence — don’t pair model: "auto" with a named voice. Automatic routing may land on a vendor that has never heard of it.
What it costs, and the container caveat
curl -sS "https://api.infrai.cc/v1/ai/models?capability=tts&available=true" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"object": "list",
"capability": "tts",
"available_only": true,
"count": 1,
"data": [
{ "id": "qwen3-tts-flash", "owned_by": "alibaba_intl", "capability": "tts", "available": true, "price_usd": 0.0, "unit": "per_1k_char" }
]
}
Read price_usd and unit off that response rather than out of a paragraph. Two things about the structure survive any repricing and are worth internalising: the meter is per 1,000 characters, not per call, so a 2,000-word article costs roughly twelve times what a one-line notification costs; and the catalogue only lists models a verified vendor will actually serve today, which is why count moves. Speech rates have been falling and vendors run discount campaigns, so whatever that call returns is likelier to be a ceiling than a floor. The voices endpoint and the model endpoint are both free reads, so checking costs you nothing.
Now the caveat we promised. response_format: "mp3" is a request, not a guarantee. On alibaba_intl the same call came back with Content-Type: audio/wav and a RIFF header, while ElevenLabs honoured the MP3 request. If your player, CDN or podcast pipeline needs a specific container, check what actually arrived:
import { writeFile } from "node:fs/promises";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function speak({ input, voiceName = "Rachel", format = "mp3" }) {
const list = await fetch("https://api.infrai.cc/v1/ai/tts/voices", {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
if (!list.ok) throw new Error(`voice catalogue failed: ${list.status}`);
const { data } = await list.json();
const voice = data.items.find((v) => v.name === voiceName) ?? data.items.find((v) => v.is_default);
if (!voice) throw new Error("no servable voice found");
const res = await fetch("https://api.infrai.cc/v1/audio/speech", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
vendor: voice.vendor,
model: voice.model,
voice: voice.voice_id,
input,
response_format: format,
}),
});
if (!res.ok) throw new Error(`speech failed: ${res.status} ${await res.text()}`);
const type = res.headers.get("content-type") ?? "";
const bytes = Buffer.from(await res.arrayBuffer());
const ext = type.includes("mpeg") ? "mp3" : type.includes("wav") ? "wav" : "bin";
const file = `speech.${ext}`;
await writeFile(file, bytes);
console.log(`${voice.vendor}/${voice.model}/${voice.voice_id} -> ${file} (${type}, ${bytes.length} bytes)`);
return { file, type };
}
await speak({ input: "Release notes for version 2.4 are ready.", voiceName: "Rachel" });
Deriving the extension from Content-Type rather than from what you asked for is three lines and removes a whole class of “the file won’t play” bug reports.
| Infrai TTS | OpenAI speech API | ElevenLabs direct | |
|---|---|---|---|
| Voice namespace | per vendor and model | one set across models | one library, plus cloning |
| Catalogue endpoint | GET /v1/ai/tts/voices | documented list, no endpoint | full REST catalogue |
| Container honoured | vendor-dependent | as requested | as requested |
| Rate lookup | live, per capability | published page | published page |
| Same key also covers | storage, email, queue, chat | inference only | speech only |
Where this falls short
If voice cloning, fine-grained SSML control or emotion tags are the product, you’d be better off going to ElevenLabs directly — that catalogue is one voice here, and the pass-through doesn’t expose their full parameter surface. Buy it if the voice is the product. If data residency is the binding constraint instead, Azure OpenAI serves the same speech models from named regions with a contract to match, and that’s the right pick when procurement is driving. TTS availability also rides on vendor keys: when a key isn’t hydrated the call returns VENDOR_NOT_CONFIGURED rather than quietly substituting a different speaker, which is the honest behaviour but means you should check the catalogue count before a launch rather than after.
What you get here instead is the boring half of the job already solved. Generating the bytes is one call; the awkward part is everything after it. Those bytes go straight into PUT /v1/storage/object/put/{bucket}/{key}, you hand the listener a link from POST /v1/storage/object/presign/{bucket}/{key}, and a thousand overnight release notes go through POST /v1/queue/publish — all on the same key you just used for the speech call, with no second account, no second SDK and no second vendor onboarding. A speech API can only ever sell you step one.