Create Speech

August 24, 2026

Table of contents

  1. Model Comparison: Speech
  2. Request Headers
  3. Request Body
  4. Responses
  5. Model
  6. Text markup
  7. Word timings
  8. Streaming
    1. Events
    2. Handling the audio events
    3. How the stream ends
  9. Examples
    1. Streaming example
  10. 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
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"
}
  • account is optional when only one account is configured. If you have several MiniMax accounts configured, this parameter becomes required.
  • text is required, the words to speak. Max length depends on the model — 5000 for hd, 10000 for turbo. Supports inline markup for emotion, sounds and pauses.
  • voice_id is required, from GET speech/voices. Accepts a MiniMax voice id or the voiceId of a voice you cloned with POST speech/clone-voice.
  • model is optional. Default: speech-2.8-hd. See Model Comparison for the full list.
  • speed is optional, playback rate. Default: 1. Range 0.5 to 2.
  • vol is optional, volume. Default: 1. Minimum 1.
  • pitch is optional. Default: 0. Range -12 to 12.
  • emotion is optional. Default: Auto. Use a value from the t2a_emotion array of GET speech/config, or leave it out and mark up the text instead.
  • language_boost is optional, biases pronunciation toward one language. Default: Auto.
  • deepen_lighten, stronger_softer, nasal_crisp are optional voice effects. Default: 0. Range -100 to 100.
  • spacious_echo, lofi_telephone, robotic, auditorium_echo are optional effect switches. Default: false.
  • stream is optional, return the audio as Server-Sent Events instead of a JSON response. Default: false.
    The streaming response is 200 with Content-Type: text/event-stream, not the 201 documented below. See Streaming.
  • replyUrl is 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, minus audio_id, plus audioId, replyRef, replyUrl, code, statusLabel and statusFinal.
    It is built from the account history, so it is the lighter list shape — voice_info, model, cost_credit and effects are 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.
  • replyRef is optional, your own reference string, echoed back on the replyUrl callback. 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 hd model and 0.6 on a turbo one, rounded up. GET speech/audioId reports what was charged as cost_credit. POST speech/create-mp3 reports usage_characters instead, 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 replyUrl or 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
  • 201 Created

    {
      "audioId": "user:12345-minimax:123456789012345678-audio:178737039436895391",
      "replyRef": "your-reference-id",
      "replyUrl": "https://webhook.site/abc",
      "status": 0,
      "statusLabel": "pending",
      "statusFinal": false
    }
    
  • 400 Bad Request

    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)"
    }
    
  • 401 Unauthorized

    {
      "error": "Unauthorized",
      "code": 401
    }
    
  • 412 Precondition Failed

    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_credit is 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."
    }
    
  • 429 Too Many Requests

    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
    }
    
  • 504 Gateway Timeout

    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, so done arrives with no subtitles. Read those from GET speech/audioId, which is also where to confirm the recording if you need certainty.
  • error with 502 — the connection closed before any audio arrived.
  • error with 504 — 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.