HiAPI
Blog

Tutorial

How to Call gemini-3.1-flash-tts on HiAPI: curl, Python, and a Working Request

Oct 31, 2026 9 min read
深靛蓝色背景上多层嵌套的宽箭头依次向右上方推进,箭头前缘为杏橙色,构成规整的信号流示意图形,这是文章的封面图。

What gemini-3.1-flash-tts gives you on HiAPI

gemini-3.1-flash-tts is a text-to-speech model offered through the HiAPI unified API. You send text plus a voice configuration, and the platform returns generated audio. Because it is exposed through a single, consistent interface, you do not need separate integrations per model — the same auth header, the same base URL, and the same async job pattern apply to every TTS model on the platform.

Reach for this model when you need:

  • Multi-speaker narration. Scripts, dialogues, or podcast-style content where different segments should sound like different voices.
  • Fast turnaround. The flash family is aimed at low-latency generation, which matters for interactive apps or batch jobs with tight SLAs.
  • A predictable integration surface. One request shape covers everything from a single line of text to a multi-speaker scene.

If you are building an app that already calls other HiAPI models, adding speech is a matter of swapping the model identifier and adapting the payload — no new SDK, no new credentials flow.

Prerequisites and authentication setup

Before your first call, make sure you have:

  1. A HiAPI account and API key. Generate a key from your dashboard. Treat it as a secret — do not commit it to source control.
  2. The HiAPI base URL. All requests go to the same host; only the path changes per endpoint.
  3. A tool to make HTTP requests. curl for quick tests, and Python 3.9+ with the requests library for scripts.

Authentication is a bearer token in the Authorization header:

Authorization: Bearer $HIAPI_API_KEY

Store the key in an environment variable rather than hard-coding it:

export HIAPI_API_KEY="your_key_here"

Every endpoint described below — job submission, status checks, and file retrieval — uses this same header. If you get a 401, the header is missing, malformed, or the key has been revoked. Rotate keys from the dashboard rather than sharing one across environments.

Building the request payload for multi-speaker speech

The request body is JSON. At minimum it contains the model identifier and the input. For multi-speaker output you add a speaker configuration that maps named speakers to voices, and you mark up the input text with speaker tags.

A typical payload looks like this:

{
  "model": "gemini-3.1-flash-tts",
  "input": {
    "text": "[Host] Welcome back to the show. [Guest] Thanks for having me.",
    "multi_speaker": true,
    "speakers": [
      { "name": "Host", "voice": "voice_a" },
      { "name": "Guest", "voice": "voice_b" }
    ]
  }
}

Key points that trip people up:

  • Speaker names must match exactly. The name in the speakers array must match the tag used in the text (inside square brackets, in this example). Case-sensitive mismatch is the most common cause of a failed multi-speaker render.
  • Every tag must be declared. If the text references [Narrator] but no speaker with name: "Narrator" is defined, the request will be rejected.
  • Voice identifiers come from the platform's voice catalog. Use the identifiers exposed by HiAPI rather than guessing. For single-speaker jobs, omit multi_speaker and speakers and set a single voice field instead.
  • Text is sent as one string. Long scripts are fine, but keep an eye on per-request limits documented on the platform.

Once the payload validates, the job is accepted and returns a job identifier you will use for polling.

Running it with curl

Save the payload above as payload.json, then submit it:

curl -X POST "$HIAPI_BASE_URL/v1/audio/speech/jobs" \
  -H "Authorization: Bearer $HIAPI_API_KEY" \
  -H "Content-Type: application/json" \
  -d @payload.json

The response contains a job object with an id and a status field (typically queued or processing):

{ "id": "job_abc123", "status": "queued" }

To check progress, fetch the job by ID:

curl "$HIAPI_BASE_URL/v1/audio/speech/jobs/job_abc123" \
  -H "Authorization: Bearer $HIAPI_API_KEY"

When status becomes succeeded, the response includes an audio file reference (a URL or file ID) you can download with a follow-up request — see the polling section below. If the job fails, status will be failed and an error field will explain why.

For quick experiments, you can skip the file and inline the JSON:

curl -X POST "$HIAPI_BASE_URL/v1/audio/speech/jobs" \
  -H "Authorization: Bearer $HIAPI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gemini-3.1-flash-tts","input":{"text":"Hello from HiAPI.","voice":"voice_a"}}'

Running it with Python

The Python equivalent uses requests and a small helper to keep the polling loop readable. The payload is identical to the curl example.

import os
import time
import requests

API_KEY = os.environ["HIAPI_API_KEY"]
BASE_URL = os.environ["HIAPI_BASE_URL"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}

payload = {
    "model": "gemini-3.1-flash-tts",
    "input": {
        "text": "[Host] Welcome back to the show. [Guest] Thanks for having me.",
        "multi_speaker": True,
        "speakers": [
            {"name": "Host", "voice": "voice_a"},
            {"name": "Guest", "voice": "voice_b"},
        ],
    },
}

resp = requests.post(
    f"{BASE_URL}/v1/audio/speech/jobs",
    headers={**HEADERS, "Content-Type": "application/json"},
    json=payload,
    timeout=30,
)
resp.raise_for_status()
job = resp.json()
job_id = job["id"]
print("submitted", job_id)

while True:
    r = requests.get(f"{BASE_URL}/v1/audio/speech/jobs/{job_id}", headers=HEADERS, timeout=30)
    r.raise_for_status()
    status = r.json()
    if status["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

if status["status"] == "failed":
    raise RuntimeError(status.get("error"))

print("audio ready:", status["output"])

Notes:

  • Timeouts matter. Set them explicitly on every request; a hung polling loop is worse than a failed one.
  • Don't poll too aggressively. A two-second interval is reasonable for short scripts; back off for long ones.
  • Keep the payload in a function. In real code, parameterize text, speakers, and voice so you can reuse the same submission logic for single-speaker jobs.

Polling the async job and downloading the audio

Speech generation is asynchronous: you submit, then check status until the job reaches a terminal state. Two terminal states exist — succeeded and failed. Treat everything else as "keep waiting."

A robust polling routine should:

  1. Cap total wait time. Even a fast model can queue behind other work. Stop after a reasonable ceiling and surface the job ID so the caller can retry.
  2. Exponential backoff. Start at one or two seconds and double up to a cap. This avoids hammering the API on long jobs.
  3. Handle failures explicitly. When status == "failed", read the error field and log it — it will usually point at a payload problem (undeclared speaker, empty text, unsupported voice).

When the job succeeds, the status response includes an output reference. Download it with the same auth header:

out = status["output"]
audio = requests.get(out["url"], headers=HEADERS, timeout=60)
audio.raise_for_status()
with open("narration.wav", "wb") as f:
    f.write(audio.content)

If the response returns a file ID instead of a URL, fetch it through the corresponding file endpoint on HiAPI. Store the original job ID alongside the saved file — it is the only reliable way to trace a bad render back to its request later.

Understanding per-character pricing

gemini-3.1-flash-tts on HiAPI is billed by character count of the input text. There are no per-request or per-minute components to model — the cost of a job scales with how much text you send, which makes estimation straightforward: total characters across all scripts, times the current per-character rate.

Practical implications:

  • Estimate before you ship. Sum your expected monthly text volume and check it against the current rate shown on the pricing panel. Do not hard-code rates into your code; read them from the platform at build time or keep them in configuration.
  • Long-form content dominates cost. A ten-minute narration is mostly text cost. If you are generating audiobooks or course material, model the volume first.
  • Retries cost money. A failed job that failed after generation may still be billed depending on the failure mode. Validate payloads client-side — check that every speaker tag is declared and that text is non-empty — before submitting.
  • Cache aggressively. If the same script is rendered repeatedly (e.g., a demo or test fixture), save the resulting audio and reuse it rather than re-billing the same characters.

For current rates and any volume tiers, consult the pricing panel on the site — that is the single source of truth.

Common failure points and how to fix them

Most integration issues fall into a handful of buckets. Work through these in order.

401 Unauthorized

Your Authorization header is missing or malformed. Confirm the key is set in the environment and that the header reads exactly Bearer <key>. If the key was rotated, update your environment.

400 Bad Request — unknown speaker

The text contains a [Name] tag that is not declared in speakers, or the casing differs. Make the tag and the name field byte-for-byte identical.

400 Bad Request — empty or oversized input

Empty text fails validation immediately. Very long scripts may exceed per-request limits; split them into multiple jobs rather than truncating.

400 Bad Request — invalid voice identifier

Voice names must come from the platform's catalog. Copy the identifier exactly; do not invent friendly names.

Job stuck in processing

This is usually queueing, not a bug. Apply backoff and a total timeout. If it persists well beyond normal, log the job ID and retry once with a smaller payload to isolate whether size is the issue.

Job failed with a generation error

Read the error field. It typically points at a content or configuration issue rather than a transient fault. Fix the payload, resubmit, and avoid blind retry loops — each attempt may bill characters.

Audio downloads but won't play

Check the content type of the downloaded file before assuming corruption. Save with the correct extension for the returned format and verify the byte length is non-zero. A truncated file usually means the download request was interrupted — retry the download, not the generation.

With these checks in place, most first-day integration problems resolve in minutes rather than hours.

Related articles

View all articles →

Start building with persistent AI artifacts

Get your API key and call image, video and audio models through one task-based API.

Read the API docs