Create Speech
August 24, 2026
Table of contents
- Model Comparison: Speech
- Request Headers
- Request Body
- Responses
- Model
- Text markup
- Word timings
- Streaming
- Examples
- Try It
Converts text to speech and returns an audioId immediately. Generation continues at MiniMax, and you either wait for the replyUrl callback or poll GET speech/audioId.
Use it for anything longer than a sentence or two. POST speech/create-mp3 holds the connection open until the audio is finished, so it stops at 3,000 characters. This one takes 5,000, or 10,000 on a turbo model — roughly a quarter of an hour of speech.
Model Comparison: Speech
| Model | Max characters | Credits per character | Status |
|---|---|---|---|
speech-2.8-hd | 5,000 | 1 | Current, and the default |
speech-2.8-turbo | 10,000 | 0.6 | Current |
speech-2.6-hd | 5,000 | 1 | Previous generation |
speech-2.6-turbo | 10,000 | 0.6 | Previous generation |
speech-2.5-hd-preview | 5,000 | 1 | Superseded by 2.6 |
speech-2.5-turbo-preview | 10,000 | 0.6 | Superseded by 2.6 |
speech-02-hd | 5,000 | 1 | Legacy |
speech-02-turbo | 10,000 | 0.6 | Legacy |
speech-01-hd | 5,000 | 1 | Legacy |
speech-01-turbo | 10,000 | 0.6 | Legacy |
Use speech-2.8-hd unless you have a reason not to. The older models stay selectable because they are still live at MiniMax, and because regenerating audio to match something made earlier needs the model that made it — not because they are worth picking for new work.
Within a generation, hd and turbo are the same model tuned differently. turbo costs 40% less per character and accepts twice the text in a single call. It is not measurably faster — on identical 990-character text the two came out level — so pick turbo for the cheaper rate and the higher ceiling, and hd when voice quality matters most.
Sending more than a model accepts returns 400 before anything is generated, so an oversized request never costs credits. The message names the model and its limit — Parameter text length (5001) exceeds 5000 characters for model speech-2.8-hd — except at the absolute 10,000 ceiling, which is checked first and reports the limit without the model.
600+ pre-built voices provided by GET speech/voices, tagged for filtering with tag_list:
- Languages: 40, from English, Chinese (Mandarin and Cantonese), Japanese and Korean through to Thai, Hindi, Tamil and Afrikaans
- Emotions: happy, sad, angry, fearful, disgusted, surprised, neutral, fluent
- Accents:
EN-US (General),EN-Australian,EN-British,EN-Indian,CN-Northern,CN-Southern - Ages:
Child,Young,Middle-aged,Elderly - Genders:
Male,Female
tag_list matches these strings exactly, including case. A tag it does not recognise is ignored rather than rejected, so a typo silently widens the result instead of narrowing it — Female,Middle-Aged returns the same 310 voices as Female alone, spanning every age. Check the count when you add a filter. GET speech/config returns the tags the picker UI offers, updated as MiniMax adds to them. Voices carry others besides — Documentary, Calm and Adult all match voices without appearing there.
https://api.useapi.net/v1/minimax/speech/create
Request Headers
Authorization: Bearer {API token}
Content-Type: application/json
API tokenis required, see Setup useapi.net for details.
Request Body
{
"account": "123456789012345678",
"text": "Good morning. Here is the weather for the week ahead.",
"voice_id": "380426458095854",
"model": "speech-2.8-hd",
"speed": 1,
"vol": 1,
"pitch": 0,
"emotion": "happy",
"language_boost": "English",
"stream": false,
"replyUrl": "https://webhook.site/abc",
"replyRef": "your-reference-id"
}
accountis optional when only one account is configured. If you have several MiniMax accounts configured, this parameter becomes required.textis required, the words to speak. Max length depends on the model —5000forhd,10000forturbo. Supports inline markup for emotion, sounds and pauses.voice_idis required, from GET speech/voices. Accepts a MiniMax voice id or thevoiceIdof a voice you cloned with POST speech/clone-voice.modelis optional. Default:speech-2.8-hd. See Model Comparison for the full list.speedis optional, playback rate. Default:1. Range0.5to2.volis optional, volume. Default:1. Minimum1.pitchis optional. Default:0. Range-12to12.emotionis optional. Default:Auto. Use a value from thet2a_emotionarray of GET speech/config, or leave it out and mark up the text instead.language_boostis optional, biases pronunciation toward one language. Default:Auto.deepen_lighten,stronger_softer,nasal_crispare optional voice effects. Default:0. Range-100to100.spacious_echo,lofi_telephone,robotic,auditorium_echoare optional effect switches. Default:false.streamis optional, return the audio as Server-Sent Events instead of a JSON response. Default:false.
The streaming response is200withContent-Type: text/event-stream, not the201documented below. See Streaming.replyUrlis optional, we will send a POST request to this URL when generation completes. Maximum length 1024 characters. Callback body has the same JSON shape as one row of GET speech, minusaudio_id, plusaudioId,replyRef,replyUrl,code,statusLabelandstatusFinal.
It is built from the account history, so it is the lighter list shape —voice_info,model,cost_creditandeffectsare only on GET speech/audioId.
If a generation is still unfinished 10 minutes after it was submitted we stop tracking it and POST{"code": 400, "error": "More than 10 minutes have passed, the task has expired."}to the same URL.replyRefis optional, your own reference string, echoed back on thereplyUrlcallback. Maximum length 1024 characters.
Notes:
- Store the
audioId. MiniMax assigns no identifier at submit time, so it is the only handle on the generation. It resolves for 7 days. - Speech is billed per character, 1 credit on an
hdmodel and 0.6 on aturboone, rounded up. GET speech/audioIdreports what was charged ascost_credit. POST speech/create-mp3 reportsusage_charactersinstead, the character count before the ratio. - Generation time scales with the length of the text, from a few seconds up to about 80 seconds at the 10,000-character ceiling. Use
replyUrlor poll rather than assuming a fixed wait. - Speech bills against the MiniMax audio subscription, the same one music uses. Without it the call returns
412, however much video credit the account holds.
Responses
-
{ "audioId": "user:12345-minimax:123456789012345678-audio:178737039436895391", "replyRef": "your-reference-id", "replyUrl": "https://webhook.site/abc", "status": 0, "statusLabel": "pending", "statusFinal": false } -
Text longer than the chosen model accepts:
{ "error": "Parameter text length (6000) exceeds 5000 characters for model speech-2.8-hd", "code": 400 }An unknown
voice_id:{ "error": "Incorrect voice_id (2600006)" } -
{ "error": "Unauthorized", "code": 401 } -
The account cannot pay for the generation. MiniMax reports this two different ways, and both mean the same thing — check the speech balance with GET features, where
audio.total_creditis the MiniMax audio wallet the API bills against.{ "error": "Insufficient credits. Please recharge to get more credits or try again next day." }{ "error": "MiniMax rejected the generation — the account is out of speech credits. Confirm with GET /features (audio.total_credit) and top up at https://www.minimax.io/audio/subscribe." } -
The account already has its maximum number of speech generations in flight. The running generations are unaffected — retry once one finishes. The ceiling depends on the MiniMax plan. A free account allows three.
{ "error": "This account already has the maximum number of speech generations running. Retry once one completes.", "code": 429 } -
MiniMax accepted the connection but sent nothing back within 30 seconds.
{ "error": "minimax did not respond within 30 seconds", "code": 504 } -
596 Account Error
{ "error": "Your minimax account has pending error. Please address this issue at https://useapi.net/docs/api-minimax-v1/post-minimax-accounts-account before making any new API calls.", "code": 596 }
Model
{ // TypeScript, all fields are optional
audioId: string, // Pass to GET speech/audioId
replyRef: string,
replyUrl: string,
status: number, // 0 while pending
statusLabel: string, // pending
statusFinal: boolean // false until the generation completes
}
Text markup
Three kinds of markup can be embedded directly in text, and they can be combined. Only emotion tags are excluded from billing — sound and pause markup counts toward the character total like any other text.
| Markup | Example | Values |
|---|---|---|
| Emotion | {happy}Great news!{/happy} | happy, sad, angry, fearful, disgusted, surprised, neutral, fluent. Max 15 spans per request |
| Sound | That's wonderful (chuckle) | laughs, chuckle, coughs, clear-throat, groans, breath, pant, inhale, exhale, gasps, sniffs, sighs, snorts, burps, lip-smacking, humming, hissing, emm, sneezes |
| Pause | Wait for it <#0.5#> there! | Seconds, from 0.01 to 99.99 |
{
"text": "{happy}The results are in{/happy} (chuckle) <#0.5#> and we did it."
}
Word timings
Word timings come back as a subtitles array with millisecond boundaries per phrase, which is what captions or a karaoke-style display need.
They reach you on two paths only: the SSE done event, and POST speech/create-mp3. MiniMax does not store them, so neither GET speech/audioId nor the replyUrl callback can return them — those report has_srt to tell you timings existed, but not the timings themselves. If you need captions for text over 3,000 characters, use stream and keep the done payload.
"subtitles": [
{ "text": "The results are in", "time_begin": 0, "time_end": 1211.9, "text_begin": 0, "text_end": 18, "timestamped_words": [] }
]
Use time_begin and time_end. The text_begin and text_end fields are reported by MiniMax but do not reliably index into either the full text or the phrase, so they are passed through rather than interpreted.
Streaming
Set stream to true and the response is 200 with Content-Type: text/event-stream, instead of the 201 JSON documented in Responses. The response carries an X-Audio-Id header with the audioId.
Streaming trades the callback for a shorter wait — the first audio arrives about four seconds in, rather than after the whole generation. The MP3 arrives in pieces that have to be joined, so buffer them as they come.
Events
| Event | Description | When Sent |
|---|---|---|
audio | { "audioId": "...", "format": "mp3" \| "wav", "complete": false, "chunk": "<base64>" } | As each piece is rendered |
done | { "audioId": "...", "subtitles": [...], "usage_characters": 112 } | When the generation finishes |
error | { "error": "...", "code": 412 } | MiniMax rejected the generation, the connection closed before any audio arrived (502), or the 5 minute cap fired (504) — code carries the HTTP status |
: keepalive | An SSE comment, not an event | Every 10 seconds |
Handling the audio events
Two different things arrive on this event, and format tells them apart.
format: "mp3" events are chunks, and complete is false. Append them in arrival order and the result is a plain MP3. Short text sends a single chunk, but a 10,000-character generation sends around 40 of them over a minute or two, so treat it as a stream rather than assuming one.
format: "wav" with complete: true is the same speech again as a whole WAV file, delivered after the MP3 chunks. It is roughly four times the size and is not part of the MP3 — do not append it. It arrives reliably on short text, and on very long generations the stream may end after the MP3 chunks without it.
The simplest correct client keeps the MP3 chunks, ignores the WAV, and assembles once the stream ends — using done for the subtitles when it arrives, rather than as the trigger to assemble.
How the stream ends
Every stream terminates with exactly one of done or error, so you never have to treat a closed connection as ambiguous.
done— the audio is finished. On a long generation MiniMax stops after the last MP3 chunk and drops the connection without sending its final frame, sodonearrives with nosubtitles. Read those from GET speech/audioId, which is also where to confirm the recording if you need certainty.errorwith502— the connection closed before any audio arrived.errorwith504— the generation ran past the 5 minute cap.
None of these lose the recording. A streamed generation is registered with the scheduler exactly like the default mode, so it still reconciles, replyUrl still fires, and the audioId from the X-Audio-Id header still resolves through GET speech/audioId. Hanging up mid-stream is safe for the same reason.
Examples
-
curl "https://api.useapi.net/v1/minimax/speech/create" \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "text": "Good morning. Here is the weather for the week ahead.", "voice_id": "380426458095854", "model": "speech-2.8-turbo", "replyUrl": "https://webhook.site/abc" }' -
const response = await fetch("https://api.useapi.net/v1/minimax/speech/create", { method: "POST", headers: { "Authorization": "Bearer …", "Content-Type": "application/json" }, body: JSON.stringify({ text: "Good morning. Here is the weather for the week ahead.", voice_id: "380426458095854", model: "speech-2.8-turbo", replyUrl: "https://webhook.site/abc" }) }); const { audioId } = await response.json(); console.log(audioId); -
import requests response = requests.post( "https://api.useapi.net/v1/minimax/speech/create", headers={ "Authorization": "Bearer …", "Content-Type": "application/json" }, json={ "text": "Good morning. Here is the weather for the week ahead.", "voice_id": "380426458095854", "model": "speech-2.8-turbo", "replyUrl": "https://webhook.site/abc" } ) print(response.json()["audioId"])
Streaming example
Set stream and the response is an event stream rather than JSON. Two pieces: read the events, then play the chunks. Both are folded up — open what you need.
Read the stream — collect the MP3 chunks and the subtitles
const response = await fetch("https://api.useapi.net/v1/minimax/speech/create", {
method: "POST",
headers: {
"Authorization": "Bearer …",
"Content-Type": "application/json"
},
body: JSON.stringify({
text: "Good morning. Here is the weather for the week ahead.",
voice_id: "380426458095854",
stream: true
})
});
const audioId = response.headers.get("X-Audio-Id");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const chunks = [];
let subtitles = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop();
for (const part of parts) {
const event = part.match(/^event: (.+)$/m)?.[1];
const data = part.match(/^data: (.+)$/m)?.[1];
if (!event || !data) continue;
const payload = JSON.parse(data);
if (event === "audio" && payload.format === "mp3")
chunks.push(Uint8Array.from(atob(payload.chunk), c => c.charCodeAt(0)));
if (event === "done")
subtitles = payload.subtitles;
if (event === "error")
console.error(payload.error);
}
}
const mp3 = new Blob(chunks, { type: "audio/mpeg" });
console.log(audioId, mp3.size, subtitles);
That collects the whole recording and plays it at the end. To hear it while it is still rendering, feed each chunk to a MediaSource — it splices MP3 frames correctly, which decoding chunks individually does not:
Progressive player — play the stream as it arrives
const audio = document.querySelector("audio");
const mediaSource = new MediaSource();
audio.src = URL.createObjectURL(mediaSource);
const ready = new Promise(resolve =>
mediaSource.addEventListener("sourceopen", resolve, { once: true }));
await ready;
const sourceBuffer = mediaSource.addSourceBuffer("audio/mpeg");
const pending = [];
const flush = () => {
if (sourceBuffer.updating || !pending.length) return;
sourceBuffer.appendBuffer(pending.shift());
};
sourceBuffer.addEventListener("updateend", flush);
/* inside the event loop above, instead of collecting into `chunks` */
if (event === "audio" && payload.format === "mp3") {
pending.push(Uint8Array.from(atob(payload.chunk), c => c.charCodeAt(0)));
flush();
if (audio.paused) audio.play().catch(() => {});
}
/* once the stream ends, after the queue has drained */
if (event === "done") {
const close = () => (pending.length || sourceBuffer.updating)
? setTimeout(close, 50)
: mediaSource.endOfStream();
close();
}
Two things worth knowing. Append, do not decode. Only the first chunk carries an MP3 header and the rest are bare frames, so decoding them individually and splicing the results produces gaps and overlaps — MediaSource handles the frame boundaries for you. And appendBuffer is asynchronous: appending again while updating is true throws, hence the queue and the updateend handler.
Check MediaSource.isTypeSupported("audio/mpeg") first and fall back to assembling a Blob at the end, as in the example above.
Try It
Enter your text and generate. The response is an audioId you can paste into GET speech/audioId to collect the finished recording.