Text to speech and voice cloning, over HTTP
Send text, get an MP3. Clone a voice from a short recording. Every call is a plain HTTPS request with your API key.
- Base URL
https://fsmb616.com/v1- Authentication
- Header
xi-api-key: YOUR_API_KEY - Responses
- JSON. Audio is delivered as MP3.
Your first audio file
Speech is generated in the background, so it takes two calls: submit the text, then collect the result.
Submit the text
The call returns straight away with a
task_id.Requestcurl -X POST "https://fsmb616.com/v1/text-to-speech/" \ -H "xi-api-key: YOUR_API_KEY" \ -F "text=Hello from FSMB" \ -F "voice_id=minimax_209533299589198" \ -F "speed=1"Response{ "success": true, "task_id": "7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10" }Collect the result
Ask for the task every few seconds until
statusisdone, then downloadaudio_url. Or pass areceive_urland we will call you when it is ready (see Webhooks).Requestcurl "https://fsmb616.com/v1/tasks/?id=7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10" \ -H "xi-api-key: YOUR_API_KEY"Response once finished{ "success": true, "task_id": "7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10", "status": "done", "characters": 17, "audio_url": "https://fsmb616.com/v1/files/7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10.mp3" }
Authentication
Send your API key in the xi-api-key header on every request. Each key has its own character quota (see Limits and billing).
A missing or wrong key returns 401. A key we have disabled returns 403.
Text to speech
/v1/text-to-speech/Starts a speech task. Send the fields as multipart/form-data or application/x-www-form-urlencoded. JSON request bodies are not supported.
| Field | Description |
|---|---|
textrequired | The text to speak. Every character counts against your quota. |
voice_idrequired | A voice from the voice list, or a voice you cloned. Example: minimax_209533299589198. |
speedoptional | Speaking speed from 0.5 to 1.5. Default 1. |
receive_urloptional | An HTTPS address (port 443) that we call once when the task finishes. See Webhooks. |
curl -X POST "https://fsmb616.com/v1/text-to-speech/" \
-H "xi-api-key: YOUR_API_KEY" \
-F "text=Hello from FSMB" \
-F "voice_id=minimax_209533299589198" \
-F "speed=1" \
-F "receive_url=https://your-site.com/fsmb-webhook"import requests
r = requests.post(
"https://fsmb616.com/v1/text-to-speech/",
headers={"xi-api-key": "YOUR_API_KEY"},
data={
"text": "Hello from FSMB",
"voice_id": "minimax_209533299589198",
"speed": "1",
"receive_url": "https://your-site.com/fsmb-webhook", # optional
},
timeout=30,
)
print(r.status_code, r.json())const form = new FormData();
form.append("text", "Hello from FSMB");
form.append("voice_id", "minimax_209533299589198");
form.append("speed", "1");
form.append("receive_url", "https://your-site.com/fsmb-webhook"); // optional
const res = await fetch("https://fsmb616.com/v1/text-to-speech/", {
method: "POST",
headers: { "xi-api-key": "YOUR_API_KEY" },
body: form,
});
console.log(res.status, await res.json());<?php
$ch = curl_init("https://fsmb616.com/v1/text-to-speech/");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
"text" => "Hello from FSMB",
"voice_id" => "minimax_209533299589198",
"speed" => "1",
"receive_url" => "https://your-site.com/fsmb-webhook", // optional
],
CURLOPT_HTTPHEADER => ["xi-api-key: YOUR_API_KEY"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
echo curl_exec($ch);
curl_close($ch);On success you get {"success": true, "task_id": "..."} and the characters are deducted from your quota. Short texts usually finish within 10 to 30 seconds; longer texts take proportionally longer.
If the request is rejected (a 4xx status), nothing is deducted. See Errors.
Check a task
/v1/tasks/?id=TASK_IDReturns the current state of a task you created. A task is visible only to the key that created it.
| Field | Description |
|---|---|
status | processing, done or failed. |
characters | Characters this task used. |
audio_url | Only when status is done. The MP3 address, see Download audio. |
curl "https://fsmb616.com/v1/tasks/?id=TASK_ID" \
-H "xi-api-key: YOUR_API_KEY"import requests
s = requests.get(
"https://fsmb616.com/v1/tasks/",
headers={"xi-api-key": "YOUR_API_KEY"},
params={"id": "TASK_ID"},
timeout=30,
).json()
print(s["status"], s.get("audio_url"))const res = await fetch("https://fsmb616.com/v1/tasks/?id=TASK_ID", {
headers: { "xi-api-key": "YOUR_API_KEY" },
});
const s = await res.json();
console.log(s.status, s.audio_url);<?php
$ch = curl_init("https://fsmb616.com/v1/tasks/?id=" . urlencode("TASK_ID"));
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["xi-api-key: YOUR_API_KEY"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$s = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $s["status"] ?? "unknown";Poll every 3 to 5 seconds and stop after a few minutes. Task records are kept for at least 14 days.
Webhooks
If you send receive_url with a speech request, we make one POST to that address when the task ends, with a JSON body:
{
"id": "7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10",
"task_id": "7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10",
"status": "done",
"characters": 17,
"progress": 100,
"audio_url": "https://fsmb616.com/v1/files/7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10.mp3"
}{
"id": "7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10",
"task_id": "7f3c2a1e-5b4d-4c8a-9e21-3d6b0a9c4f10",
"status": "failed",
"characters": 17
}What to expect
- The address must be HTTPS on port 443 and resolve to a public server. Private or internal addresses are never called, and a
receive_urlthat breaks these rules is rejected withinvalid_receive_url. - We try once, wait up to 10 seconds, and do not follow redirects. There are no retries, so also keep a polling fallback.
- Answer with any
2xxstatus and keep the handler fast. - The call is not signed. Treat it as a nudge: confirm the result with
GET /v1/tasks/and your key before you act on it.
A minimal receiver that does exactly that:
from flask import Flask, request
import requests
app = Flask(__name__)
API = "https://fsmb616.com/v1"
HEADERS = {"xi-api-key": "YOUR_API_KEY"}
@app.post("/fsmb-webhook")
def fsmb_webhook():
task_id = (request.get_json(silent=True) or {}).get("task_id", "")
# Do not trust the payload on its own: confirm the result with the API.
s = requests.get(f"{API}/tasks/", headers=HEADERS, params={"id": task_id}, timeout=30).json()
if s.get("status") == "done":
print("audio ready:", s["audio_url"]) # download and store it here
elif s.get("status") == "failed":
print("task failed:", task_id)
return "", 204const express = require("express");
const app = express();
app.use(express.json());
app.post("/fsmb-webhook", async (req, res) => {
const taskId = (req.body && req.body.task_id) || "";
res.sendStatus(204); // answer quickly, then confirm the result with the API
const r = await fetch(`https://fsmb616.com/v1/tasks/?id=${encodeURIComponent(taskId)}`, {
headers: { "xi-api-key": process.env.FSMB_API_KEY },
});
const s = await r.json();
if (s.status === "done") console.log("audio ready:", s.audio_url); // download and store it
if (s.status === "failed") console.log("task failed:", taskId);
});
app.listen(3000);<?php
$payload = json_decode(file_get_contents("php://input"), true);
$taskId = is_array($payload) ? (string)($payload["task_id"] ?? "") : "";
http_response_code(204);
// Confirm the result with the API instead of trusting the payload alone.
$ch = curl_init("https://fsmb616.com/v1/tasks/?id=" . urlencode($taskId));
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["xi-api-key: YOUR_API_KEY"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$status = json_decode(curl_exec($ch), true);
curl_close($ch);
if (($status["status"] ?? "") === "done") {
// download $status["audio_url"] and store it
}Download audio
/v1/files/TASK_ID.mp3This is the audio_url you receive. The file is an MP3 and downloading it needs no API key.
Voices
/v1/voices/Lists the ready-made voices you can use as voice_id.
| Parameter | Description |
|---|---|
provider | One of minimax (default), elevenlabs, edge, kokoro, vbee, fishaudio. Anything else returns 400 invalid_provider. |
page, page_size | Paging. Defaults are page 1 and 20 voices. |
language | Optional filter, for example English. |
gender | Optional filter: Male or Female. |
curl "https://fsmb616.com/v1/voices/?provider=minimax&page=1&page_size=20" \
-H "xi-api-key: YOUR_API_KEY"import requests
r = requests.get(
"https://fsmb616.com/v1/voices/",
headers={"xi-api-key": "YOUR_API_KEY"},
params={"provider": "minimax", "page": 1, "page_size": 20},
timeout=30,
)
for v in r.json()["data"]:
print(v["voice_id"], v["name"])const url = "https://fsmb616.com/v1/voices/?provider=minimax&page=1&page_size=20";
const res = await fetch(url, { headers: { "xi-api-key": "YOUR_API_KEY" } });
const body = await res.json();
for (const v of body.data) console.log(v.voice_id, v.name);<?php
$ch = curl_init("https://fsmb616.com/v1/voices/?provider=minimax&page=1&page_size=20");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["xi-api-key: YOUR_API_KEY"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($body["data"] ?? [] as $v) {
echo $v["voice_id"] . " " . $v["name"] . "\n";
}The response has success, format_version and a data list. Each item describes one voice:
| Field | Description |
|---|---|
voice_id | Pass this as voice_id in a speech request, exactly as returned. |
name | The voice's display name. |
language, locale | The main language, for example English and en. |
gender | Male or Female. |
age, accent | Descriptive labels, for example Middle Age and EN-US (General). |
description, category | Free text. Can be null. |
use_cases, descriptives, tags | Lists of labels you can show or filter on. |
languages | The languages the voice supports. Each item has language, locale, accent and a preview_url. |
{
"success": true,
"format_version": "2026-05-29",
"data": [
{
"voice_id": "minimax_209533299589191",
"name": "Bossy Lady",
"description": null,
"language": "English",
"locale": "en",
"gender": "Female",
"age": "Middle Age",
"accent": "EN-US (General)",
"category": null,
"use_cases": ["Mature"],
"descriptives": [],
"tags": ["English", "Female", "Middle Age", "Mature", "EN-US (General)", "en"],
"languages": [
{
"language": "English",
"locale": "en",
"accent": "EN-US (General)",
"preview_url": "https://fsmb616.com/v1/preview/?..."
}
]
}
]
}Not every voice fills every field, so rely only on voice_id and name being present. A preview_url is a short sample hosted by us (https://fsmb616.com/v1/preview/...). It opens directly in an audio or image tag and needs no API key.
A voice ID starts with its provider: minimax_, elevenlabs_, edge_, kokoro_, vbee_ or fishaudio_. Voices you clone start with clone_ and are not part of this list.
Voice cloning
/v1/text-to-speech/voice-clone/Creates a new voice from a recording. Send multipart/form-data.
| Field | Description |
|---|---|
filesrequired | The audio file (MP3, WAV or M4A). |
nameoptional | A label for the voice, up to 100 characters. Default My Voice. |
curl -X POST "https://fsmb616.com/v1/text-to-speech/voice-clone/" \
-H "xi-api-key: YOUR_API_KEY" \
-F "name=My Voice" \
-F "files=@sample.mp3"import requests
with open("sample.mp3", "rb") as f:
r = requests.post(
"https://fsmb616.com/v1/text-to-speech/voice-clone/",
headers={"xi-api-key": "YOUR_API_KEY"},
data={"name": "My Voice"},
files={"files": ("sample.mp3", f, "audio/mpeg")},
timeout=180,
)
print(r.status_code, r.json())const { readFile } = require("node:fs/promises");
async function cloneVoice() {
const form = new FormData();
form.append("name", "My Voice");
form.append("files", new Blob([await readFile("sample.mp3")], { type: "audio/mpeg" }), "sample.mp3");
const res = await fetch("https://fsmb616.com/v1/text-to-speech/voice-clone/", {
method: "POST",
headers: { "xi-api-key": "YOUR_API_KEY" },
body: form,
});
console.log(res.status, await res.json());
}
cloneVoice();<?php
$ch = curl_init("https://fsmb616.com/v1/text-to-speech/voice-clone/");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
"name" => "My Voice",
"files" => new CURLFile("sample.mp3", "audio/mpeg", "sample.mp3"),
],
CURLOPT_HTTPHEADER => ["xi-api-key: YOUR_API_KEY"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 180,
]);
echo curl_exec($ch);
curl_close($ch);{
"success": true,
"data": { "voice_id": "clone_2673890", "voice_name": "My Voice" }
}Use the returned voice_id (it already starts with clone_) as the voice_id in a speech request. Cloning does not use your character quota.
403 voice_not_allowed. Keep your voice IDs and your API key private.For a good clone
- One speaker, clear speech, 30 to 120 seconds, with no music or background noise.
- Keep the file under 10 MB. Cloning can take a minute, so allow a long timeout.
Errors
Errors return a non-2xx status and a JSON body with "success": false. Check the HTTP status first, then the error.code.
| Status | Code | What to do |
|---|---|---|
| 401 | missing_api_key | Send the xi-api-key header. |
| 401 | invalid_api_key | Check the key for typos. |
| 403 | key_disabled | The key was disabled. Contact support. |
| 403 | voice_not_allowed | The voice is a clone that belongs to another API key. |
| 400 | missing_text | Send a non-empty text. |
| 400 | invalid_receive_url | receive_url must be HTTPS on port 443. |
| 400 | missing_file | Cloning needs the audio in the files field, and the upload must succeed. |
| 400 | invalid_provider | Use one of the providers listed under Voices. |
| 400 | invalid_voice_id | The voice_id is malformed, for example it contains spaces or unusual characters. |
| 400 | invalid_task_id | The id is not a task ID. |
| 402 | quota_exhausted | No characters left. Top up your plan. |
| 402 | insufficient_quota | The text is longer than what remains. The body includes remaining_characters. |
| 404 | task_not_found | The task does not exist, has expired, or belongs to another key. |
| 404 | voice_not_found | The voice_id does not exist. Use an ID from the voice list or one of your own clones. |
| 502 | upstream_unreachable, upstream_error | A temporary problem on our side. Nothing was charged. Retry after a short wait. |
Other 4xx responses carry a message that explains the problem, for example a voice_id that is not valid.
Limits and billing
- One character is one unit. The characters in
text(letters, spaces and punctuation) are deducted from the key's quota when a task is accepted. - Rejected requests are free. Nothing is deducted for a
4xxor5xxresponse. - Failed tasks. If a task ends with
status: failed, contact support with itstask_id. - Cloning does not use the character quota.
- Retention. Audio files stay for at least 7 days; task records for at least 14 days.
- Volume. There is no per-minute limit documented here. If you plan to send many requests at once, tell us first.
To check or top up your quota, contact support below.
Full examples
Text in, MP3 file out. Each example submits the text, waits for the task, and saves the audio.
import time
import requests
API = "https://fsmb616.com/v1"
HEADERS = {"xi-api-key": "YOUR_API_KEY"}
def text_to_mp3(text, voice_id, out_path, timeout=180):
r = requests.post(
f"{API}/text-to-speech/",
headers=HEADERS,
data={"text": text, "voice_id": voice_id, "speed": "1"},
timeout=30,
)
body = r.json()
if not body.get("success"):
raise RuntimeError(body)
task_id = body["task_id"]
deadline = time.time() + timeout
while time.time() < deadline:
s = requests.get(f"{API}/tasks/", headers=HEADERS, params={"id": task_id}, timeout=30).json()
if s.get("status") == "done":
audio = requests.get(s["audio_url"], timeout=120)
audio.raise_for_status()
with open(out_path, "wb") as f:
f.write(audio.content)
return out_path
if s.get("status") == "failed":
raise RuntimeError(f"Task {task_id} failed")
time.sleep(4)
raise TimeoutError(f"Task {task_id} is still processing")
text_to_mp3("Hello from FSMB.", "minimax_209533299589198", "hello.mp3")const { writeFile } = require("node:fs/promises");
const API = "https://fsmb616.com/v1";
const KEY = process.env.FSMB_API_KEY;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function textToMp3(text, voiceId, outPath) {
const form = new FormData();
form.append("text", text);
form.append("voice_id", voiceId);
form.append("speed", "1");
const submit = await (
await fetch(`${API}/text-to-speech/`, {
method: "POST",
headers: { "xi-api-key": KEY },
body: form,
})
).json();
if (!submit.success) throw new Error(JSON.stringify(submit));
const deadline = Date.now() + 180000;
while (Date.now() < deadline) {
const res = await fetch(`${API}/tasks/?id=${submit.task_id}`, { headers: { "xi-api-key": KEY } });
const s = await res.json();
if (s.status === "done") {
const audio = await fetch(s.audio_url);
await writeFile(outPath, Buffer.from(await audio.arrayBuffer()));
return outPath;
}
if (s.status === "failed") throw new Error(`Task ${submit.task_id} failed`);
await sleep(4000);
}
throw new Error("Timed out waiting for audio");
}
textToMp3("Hello from FSMB.", "minimax_209533299589198", "hello.mp3").then(console.log);Use it on your website
A website has two sides: the pages your visitors see (the frontend) and your own server (the backend). Your API key must live only on the backend. The page talks to your backend, and your backend talks to FSMB.
- The visitor types text and clicks a button. The page sends it to your backend.
- Your backend calls
POST /v1/text-to-speech/with your key and hands thetask_idback to the page. - The page asks your backend for the task every few seconds. Your backend calls
GET /v1/tasks/and returns the status. - When the status is
done, the page plays theaudio_url.
1. Your backend
Pick the language your site runs on. Every version offers the same two addresses, /api/tts and /api/task (the PHP version uses two files, api/tts.php and api/task.php). It checks the input, adds your key, and passes the answer back to your page.
# app.py - install: pip install flask requests
# run: FSMB_API_KEY=your_key flask run
import os
import re
import requests
from flask import Flask, jsonify, request
app = Flask(__name__)
API = "https://fsmb616.com/v1"
KEY = os.environ["FSMB_API_KEY"] # the key lives only here, never in page code
HEADERS = {"xi-api-key": KEY}
MAX_TEXT = 2000 # your own limit per request
def forward(response):
try:
data = response.json()
except ValueError:
return jsonify(error="Bad response"), 502
return jsonify(data), response.status_code
@app.post("/api/tts")
def tts():
body = request.get_json(silent=True) or {}
text = str(body.get("text", "")).strip()
voice_id = str(body.get("voice_id", "minimax_209533299589198"))
if not text or len(text) > MAX_TEXT:
return jsonify(error="Text is empty or too long"), 400
if not re.fullmatch(r"[A-Za-z0-9_.\-]{1,120}", voice_id):
return jsonify(error="Invalid voice"), 400
try:
r = requests.post(
f"{API}/text-to-speech/",
headers=HEADERS,
data={"text": text, "voice_id": voice_id, "speed": "1"},
timeout=30,
)
except requests.RequestException:
return jsonify(error="Service unavailable, please try again"), 502
return forward(r)
@app.get("/api/task")
def task():
task_id = request.args.get("id", "")
if not re.fullmatch(r"[A-Za-z0-9\-]{36}", task_id):
return jsonify(error="Invalid task id"), 400
try:
r = requests.get(f"{API}/tasks/", headers=HEADERS, params={"id": task_id}, timeout=30)
except requests.RequestException:
return jsonify(error="Service unavailable, please try again"), 502
return forward(r)// server.js - run: FSMB_API_KEY=your_key node server.js (Node 18 or newer, no packages needed)
const http = require("node:http");
const API = "https://fsmb616.com/v1";
const KEY = process.env.FSMB_API_KEY; // the key lives only here, never in page code
const MAX_TEXT = 2000; // your own limit per request
if (!KEY) {
console.error("Set FSMB_API_KEY first.");
process.exit(1);
}
function send(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
function readJson(req) {
return new Promise((resolve, reject) => {
let raw = "";
let tooBig = false;
req.on("data", (chunk) => {
if (raw.length < 20000) raw += chunk;
else tooBig = true;
});
req.on("end", () => {
if (tooBig) return reject(new Error("too large"));
try { resolve(JSON.parse(raw || "{}")); } catch (e) { reject(e); }
});
req.on("error", reject);
});
}
http.createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/tts") {
let body;
try { body = await readJson(req); } catch { return send(res, 400, { error: "Invalid request" }); }
const text = String(body.text || "").trim();
const voiceId = String(body.voice_id || "minimax_209533299589198");
if (!text || text.length > MAX_TEXT) return send(res, 400, { error: "Text is empty or too long" });
if (!/^[A-Za-z0-9_.\-]{1,120}$/.test(voiceId)) return send(res, 400, { error: "Invalid voice" });
const form = new FormData();
form.append("text", text);
form.append("voice_id", voiceId);
form.append("speed", "1");
const r = await fetch(`${API}/text-to-speech/`, {
method: "POST",
headers: { "xi-api-key": KEY },
body: form,
});
return send(res, r.status, await r.json().catch(() => ({ error: "Bad response" })));
}
if (req.method === "GET" && url.pathname === "/api/task") {
const id = url.searchParams.get("id") || "";
if (!/^[A-Za-z0-9\-]{36}$/.test(id)) return send(res, 400, { error: "Invalid task id" });
const r = await fetch(`${API}/tasks/?id=${encodeURIComponent(id)}`, {
headers: { "xi-api-key": KEY },
});
return send(res, r.status, await r.json().catch(() => ({ error: "Bad response" })));
}
send(res, 404, { error: "Not found" });
} catch {
send(res, 502, { error: "Service unavailable, please try again" });
}
}).listen(3000, () => console.log("Listening on http://localhost:3000"));<?php
// ---------- File 1: api/tts.php ----------
// Keep the key outside your web folder, for example in an environment variable
// or in a file such as /home/youruser/fsmb_key.php that contains: <?php $FSMB_KEY = "your_key";
$KEY = getenv("FSMB_API_KEY");
header("Content-Type: application/json");
$in = json_decode(file_get_contents("php://input"), true);
$text = trim((string)($in["text"] ?? ""));
$voice = (string)($in["voice_id"] ?? "minimax_209533299589198");
if ($text === "" || mb_strlen($text) > 2000) {
http_response_code(400);
echo json_encode(["error" => "Text is empty or too long"]);
exit;
}
if (!preg_match('/^[A-Za-z0-9_.\-]{1,120}$/', $voice)) {
http_response_code(400);
echo json_encode(["error" => "Invalid voice"]);
exit;
}
$ch = curl_init("https://fsmb616.com/v1/text-to-speech/");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => ["text" => $text, "voice_id" => $voice, "speed" => "1"],
CURLOPT_HTTPHEADER => ["xi-api-key: " . $KEY],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($out === false || $code === 0) {
http_response_code(502);
echo json_encode(["error" => "Service unavailable, please try again"]);
exit;
}
http_response_code($code);
echo $out;
<?php
// ---------- File 2: api/task.php ----------
$KEY = getenv("FSMB_API_KEY");
header("Content-Type: application/json");
$id = (string)($_GET["id"] ?? "");
if (!preg_match('/^[A-Za-z0-9\-]{36}$/', $id)) {
http_response_code(400);
echo json_encode(["error" => "Invalid task id"]);
exit;
}
$ch = curl_init("https://fsmb616.com/v1/tasks/?id=" . urlencode($id));
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["xi-api-key: " . $KEY],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$out = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($out === false || $code === 0) {
http_response_code(502);
echo json_encode(["error" => "Service unavailable, please try again"]);
exit;
}
http_response_code($code);
echo $out;2. Your page
Put this in any page of your site. It never sees your key.
<textarea id="text" rows="4" maxlength="2000" placeholder="Type something to speak"></textarea>
<button id="speak" type="button">Speak</button>
<p id="status"></p>
<audio id="player" controls hidden></audio>
<script>
// These point to YOUR backend. Never call the FSMB API straight from the page (it would expose your key).
const TTS_URL = "/api/tts"; // PHP version: "/api/tts.php"
const TASK_URL = "/api/task"; // PHP version: "/api/task.php"
const $ = (id) => document.getElementById(id);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function message(data) {
if (data && data.error && data.error.message) return data.error.message;
if (data && typeof data.error === "string") return data.error;
if (data && data.message) return data.message;
return "Request failed";
}
async function speak() {
const button = $("speak");
const status = $("status");
const player = $("player");
button.disabled = true;
player.hidden = true;
status.textContent = "Working...";
try {
const res = await fetch(TTS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: $("text").value, voice_id: "minimax_209533299589198" }),
});
const job = await res.json();
if (!res.ok || !job.task_id) throw new Error(message(job));
for (let i = 0; i < 60; i++) { // about 3 minutes at most
await sleep(3000);
const r = await fetch(TASK_URL + "?id=" + encodeURIComponent(job.task_id));
const task = await r.json();
if (!r.ok) throw new Error(message(task));
if (task.status === "done") {
player.src = task.audio_url;
player.hidden = false;
player.play().catch(() => {}); // some browsers block autoplay
status.textContent = "Done";
return;
}
if (task.status === "failed") throw new Error("Generation failed");
}
throw new Error("Timed out, please try again");
} catch (e) {
status.textContent = "Error: " + e.message;
} finally {
button.disabled = false;
}
}
$("speak").addEventListener("click", speak);
</script>Before you go live
- Protect your own endpoint.
/api/ttsis public by nature, so anyone who finds it can spend your quota. Require a login or session, limit the text length (the examples allow 2000 characters), and limit how often one visitor can call it. - Keep the key off the page and out of your code repository. Use an environment variable or a file outside your web folder.
- Serve your site over HTTPS, so your visitors' text is not sent in plain text.
- Save the audio if you need it later. Links stay valid for at least 7 days. Download the file in your backend and keep your own copy.
- For busy sites, use a webhook instead of polling. Send
receive_urlwith the request. Your backend receives the result and stores the audio, and your page only reads from your own database. - Show friendly messages. Quota errors (
402) and temporary errors (502) are normal. Tell the visitor what to do, for example try again later.
Acceptable use
Only clone a voice if it is yours or you have the speaker's permission. Do not use cloned or generated voices to impersonate, deceive or defraud anyone. We may disable keys that are used this way.
Support
Write to us with your task_id and the time of the request, and we can trace it.
- +92 313 6725508
- mohammadmajid616@gmail.com