/v1/media/voices holds the saved voices of an account, vo_…, and lists the built-in voices of each model. GET
routes need the media:read scope; the other routes need media:generate.
Errors on these routes have the shape {"ok": false, "error": "voice-not-found", "code": "voice_not_found", "message": "...", "param": "voice"}. Branch on code; show message to a person. Key, scope and rate-limit errors:
Errors.
The voice object
| Field | Type | Value |
|---|---|---|
id | string | vo_ and 22 random characters; a built-in voice keeps its name, such as af_heart |
object | string | voice |
name | string | 1 to 100 characters |
description | string | 0 to 500 characters; the description of a designed voice |
category | string | cloned (from a clip), designed (from a description) or builtin (a model's own voice) |
visibility | string | private, members or public |
owner | string | you for a voice of the key's account, albastov for every other voice |
status | string | ready; pending while a designed voice is made; failed |
error | string or null | Why a designed voice failed |
language | string | A language code such as en or en-gb, set by the voice's account |
gender, accent | string | Free text set by the voice's account |
labels | object | Up to 10 string keys and string values, such as {"use": "narration"} |
duration_s | number or null | The clip's length; null for a WebM clip and a built-in voice |
transcript | string | The words of the clip; filled on first use when empty |
preview_url | string or null | A short line spoken in the voice; null until made |
preview_job | string or null | The job making the preview or the designed voice |
audio_url | string or null | The reference clip; null for a built-in voice and a voice that is not ready |
default_model | string or null | The model a speech request without model runs |
models | array | Every model that speaks the voice, recommended first |
usage_count | integer | Jobs submitted with the voice, the preview excluded |
created_at, updated_at | string or null | RFC 3339 times; null for a built-in voice |
consent | string | The consent note; present on the account's own voices only |
List voices
GET /v1/media/voices returns a page of voices the key may use, newest first.
Parameters
| Name | Type | Description |
|---|---|---|
scope | string | all (default): the account's voices and every shared voice; mine; shared: other accounts' shared voices |
model | string | Only voices the model speaks. The model's built-in voices come first on the first page and do not count toward limit |
category | string | cloned, designed or builtin; builtin needs model |
language | string | en matches en and every en-… |
q | string | Text in the name or description, any case |
limit | integer | 1 to 100, default 20 |
cursor | string | next_cursor of the page before |
Request
curl
curl "https://api.albastov.com/v1/media/voices?model=kokoro&language=en-gb&limit=5" \
-H "Authorization: Bearer $ALBASTOV_API_KEY"Python
import json
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices?model=kokoro&language=en-gb&limit=5",
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=30) as res:
for v in json.load(res)["data"]:
print(v["id"], v["gender"])JavaScript
const res = await fetch("https://api.albastov.com/v1/media/voices?model=kokoro&language=en-gb&limit=5", {
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
for (const v of (await res.json()).data) console.log(v.id, v.gender);Response
{
"object": "list",
"data": [
{
"id": "bf_alice",
"object": "voice",
"name": "bf_alice",
"description": "",
"category": "builtin",
"visibility": "public",
"owner": "albastov",
"status": "ready",
"error": null,
"language": "en-gb",
"gender": "female",
"accent": "",
"labels": {},
"duration_s": null,
"transcript": "",
"preview_url": null,
"preview_job": null,
"audio_url": null,
"default_model": "kokoro",
"models": ["kokoro"],
"usage_count": 0,
"created_at": null,
"updated_at": null
}
],
"has_more": false,
"next_cursor": null
}The response above is shortened to one of the eight voices.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | bad-param | scope, category, model, limit or cursor is not valid; param names the field |
Create a voice
POST /v1/media/voices saves a clip of 3 to 60 s as a cloned voice. The voice is ready at once; the preview
follows.
Parameters
A multipart/form-data body with file, or a JSON body with from. A field outside the list fails with
400 bad-param.
| Name | Type | Description |
|---|---|---|
file required in multipart | file | WAV, MP3, M4A, Ogg, WebM or FLAC, 3 to 60 s, 20 MB at most. The length is read from the file's header; a file without one is refused, except WebM |
from required in JSON | string | An upload of the account up_…, or a result of the account's job job:<id>/<i> |
name | string | 1 to 100 characters. Required in JSON; in multipart, defaults to the file name without its extension |
description | string | 500 characters at most |
language | string | A language code such as en, 35 characters at most |
gender | string | 40 characters at most |
accent | string | 60 characters at most |
labels | object | Up to 10 keys of 1 to 40 characters, string values of 100 at most; in multipart, the object as JSON text |
transcript | string | The words of the clip, 5,000 characters at most |
consent | string | Who the speaker is and what they agreed to, 2,000 characters at most; required to share the voice |
curl reads ; in a -F value as a field separator: put a value with ; in quotes inside the value
(-F 'consent="a; b"').
Request
curl
curl https://api.albastov.com/v1/media/voices \
-H "Authorization: Bearer $ALBASTOV_API_KEY" \
-F [email protected] \
-F name="Harbour narrator" \
-F language=en \
-F 'labels={"use": "narration"}' \
-F consent="Recorded by the speaker for audiobook narration, 2026-11-03."Python
import json
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices",
data=json.dumps({"from": "up_Q8mZr3kPwD5nTb7yXc2hLf", "name": "Harbour narrator", "language": "en",
"labels": {"use": "narration"},
"consent": "Recorded by the speaker for audiobook narration, 2026-11-03."}).encode(),
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as res:
print(json.load(res)["id"])JavaScript
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("clip.wav")], { type: "audio/wav" }), "clip.wav");
form.append("name", "Harbour narrator");
form.append("language", "en");
form.append("labels", JSON.stringify({ use: "narration" }));
form.append("consent", "Recorded by the speaker for audiobook narration, 2026-11-03.");
const res = await fetch("https://api.albastov.com/v1/media/voices", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}` },
body: form,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).id);Response
201 with the voice object.
{
"id": "vo_FLUWnoRjtFXyrBE8uWoTxL",
"object": "voice",
"name": "Harbour narrator",
"description": "",
"category": "cloned",
"visibility": "private",
"owner": "you",
"status": "ready",
"error": null,
"language": "en",
"gender": "",
"accent": "",
"labels": {"use": "narration"},
"duration_s": 8.8,
"transcript": "",
"preview_url": null,
"preview_job": "mj_uyeqTjDrzLxNLPpBKuibEi",
"audio_url": "https://api.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL/audio",
"default_model": "omnivoice",
"models": ["omnivoice", "breeze2", "higgs3", "qwen3-tts", "fireredtts3", "moss-local", "cosyvoice3", "indextts2.5", "fish-s2pro", "miotts", "voxcpm2", "chatterbox"],
"usage_count": 0,
"created_at": "2026-11-03T04:03:16.434Z",
"updated_at": "2026-11-03T04:03:16.434Z",
"consent": "Recorded by the speaker for audiobook narration, 2026-11-03."
}Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | bad-param | A field is missing, unknown or too long, the clip is outside 3 to 60 s, or its header has no length; param names the field (file, from, name, labels, ...) |
| 400 | bad-upload | The multipart body has no boundary or did not arrive whole |
| 400 | bad-json | The JSON body is not an object |
| 413 | too-large | The clip is over 20 MB |
| 415 | not-audio | The clip is not WAV, MP3, M4A, Ogg, WebM or FLAC |
| 415 | multipart-or-json | The body is neither multipart nor JSON |
| 422 | voice_quota | The account already has quota voices, 30 by default |
Design a voice
POST /v1/media/voices/design makes a synthetic voice from a description. The answer is 202 with status
pending; GET /v1/media/voices/{id} returns ready when the voice is made, or failed with error. The clip the
model makes becomes the voice's reference clip and preview.
Parameters
A JSON body. A field outside the list fails with 400 bad-param.
| Name | Type | Description |
|---|---|---|
name required | string | 1 to 100 characters |
description required | string | The voice to make, such as "female, warm, low pitch, slow", 500 characters at most |
model | string | omnivoice (default) |
text | string | The line the voice speaks in its clip, 1,000 characters at most; default a fixed passage of about 15 s |
language | string | A language code for text |
Request
curl
curl https://api.albastov.com/v1/media/voices/design \
-H "Authorization: Bearer $ALBASTOV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Night radio", "description": "male, middle-aged, low pitch, slow and warm"}'Python
import json
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices/design",
data=json.dumps({"name": "Night radio", "description": "male, middle-aged, low pitch, slow and warm"}).encode(),
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as res:
voice = json.load(res)
print(voice["id"], voice["status"])JavaScript
const res = await fetch("https://api.albastov.com/v1/media/voices/design", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Night radio", description: "male, middle-aged, low pitch, slow and warm" }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const voice = await res.json();
console.log(voice.id, voice.status);Response
202 with the voice object, category designed and status pending.
{
"id": "vo_s683xywhmvVR5pbv2tJNtn",
"object": "voice",
"name": "Night radio",
"description": "male, middle-aged, low pitch, slow and warm",
"category": "designed",
"visibility": "private",
"owner": "you",
"status": "pending",
"error": null,
"duration_s": null,
"preview_url": null,
"preview_job": "mj_7tKcQ2vWn9ZxR4pLm8YbHd",
"audio_url": null,
"default_model": "omnivoice",
"usage_count": 0
}The response above leaves out the empty fields and models.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | bad-param | A field is missing, unknown or too long, or model does not design voices; param names the field |
| 400 | bad-json | The body is not a JSON object |
| 422 | voice_quota | The account already has quota voices, 30 by default |
| 429 | budget-exhausted | The account's daily media time is used up; resets_at is the next 00:00 UTC |
| 429 | too-many-open | The key has 1,000 jobs queued or running |
Get a voice
GET /v1/media/voices/{id} returns a saved voice of the account, or a shared voice.
Parameters
| Name | Type | Description |
|---|---|---|
id required | string | The voice id vo_…, in the path |
Request
curl
curl https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh \
-H "Authorization: Bearer $ALBASTOV_API_KEY"Python
import json
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh",
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=30) as res:
voice = json.load(res)
print(voice["name"], voice["status"], voice["default_model"])JavaScript
const res = await fetch("https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh", {
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const voice = await res.json();
console.log(voice.name, voice.status, voice.default_model);Response
{
"id": "vo_hVAiUiqrYyvgKKkBvgzHnh",
"object": "voice",
"name": "Citadel docs voice",
"description": "female, young adult, moderate pitch",
"category": "designed",
"visibility": "public",
"owner": "albastov",
"status": "ready",
"error": null,
"duration_s": 14.26,
"preview_url": "https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/preview",
"audio_url": "https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/audio",
"default_model": "omnivoice"
}The response above leaves out the empty fields, models, usage_count and the times. consent is absent on another
account's voice.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | voice_not_found | No saved voice with the id, or another account's private voice; a built-in voice has no object of its own |
Update a voice
PATCH /v1/media/voices/{id} changes a voice of the account and returns the changed voice.
Parameters
A JSON body with the fields to change. A field outside the list fails with 400 bad-param.
| Name | Type | Description |
|---|---|---|
id required | string | The voice id vo_…, in the path |
name | string | 1 to 100 characters |
description | string | 500 characters at most |
language, gender, accent | string | 35, 40 and 60 characters at most |
labels | object | Replaces the labels: up to 10 keys of 1 to 40 characters, string values of 100 at most |
transcript | string | The words of the clip, 5,000 characters at most |
consent | string | The consent note, 2,000 characters at most |
visibility | string | private, members or public. Only the albastov.com owner account sets members or public; a cloned voice needs a consent note to be shared |
Request
curl
curl -X PATCH https://api.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL \
-H "Authorization: Bearer $ALBASTOV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"description": "Calm narrator for chapter readings", "labels": {"book": "harbour"}}'Python
import json
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL",
method="PATCH",
data=json.dumps({"description": "Calm narrator for chapter readings", "labels": {"book": "harbour"}}).encode(),
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as res:
print(json.load(res)["labels"])JavaScript
const res = await fetch("https://api.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL", {
method: "PATCH",
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ description: "Calm narrator for chapter readings", labels: { book: "harbour" } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).labels);Response
200 with the voice object, as in Create a voice.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | bad-param | A field is unknown, not a string or too long, or visibility is not valid; param names the field |
| 400 | bad-json | The body is not a JSON object |
| 403 | visibility_owner_only | An account other than the owner account sets members or public |
| 404 | voice_not_found | No voice with the id on the key's account |
| 422 | consent_required | A shared cloned voice without a consent note; param is consent |
Delete a voice
DELETE /v1/media/voices/{id} deletes a voice of the account with its clip and preview. A job already queued with
the voice keeps its own copy of the clip.
Parameters
| Name | Type | Description |
|---|---|---|
id required | string | The voice id vo_…, in the path |
Request
curl
curl -X DELETE https://api.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL \
-H "Authorization: Bearer $ALBASTOV_API_KEY"Python
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL",
method="DELETE",
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=30) as res:
print(res.status)JavaScript
const res = await fetch("https://api.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL", {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(res.status);Response
204 with an empty body.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | voice_not_found | No voice with the id on the key's account |
Get the voice clip
GET /v1/media/voices/{id}/audio returns the voice's reference clip as saved.
Parameters
| Name | Type | Description |
|---|---|---|
id required | string | The voice id vo_…, in the path |
Headers
| Name | Description |
|---|---|
Range | Request. A byte range, such as bytes=0-1023; the answer is 206 with Content-Range |
Content-Type | Response. The clip's audio type, such as audio/mpeg or audio/wav |
Cache-Control | Response. public, max-age=86400 for a public voice; private, max-age=300 for the rest |
Request
curl
curl https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/audio \
-H "Authorization: Bearer $ALBASTOV_API_KEY" \
--output voice.mp3Python
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/audio",
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=30) as res, open("voice.mp3", "wb") as f:
f.write(res.read())JavaScript
import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/audio", {
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
await writeFile("voice.mp3", Buffer.from(await res.arrayBuffer()));Response
200 with the clip as the body; 206 for a byte range.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | voice_not_found | No saved voice with the id, or another account's private voice |
| 409 | voice_not_ready | A designed voice still pending, or failed; status says which |
Get the voice preview
GET /v1/media/voices/{id}/preview returns a short line spoken in the voice by its default model. The preview of a
designed voice is its reference clip.
Parameters
| Name | Type | Description |
|---|---|---|
id required | string | The voice id vo_…, in the path |
Headers
| Name | Description |
|---|---|
Range | Request. A byte range, such as bytes=0-1023; the answer is 206 with Content-Range |
Content-Type | Response. The preview's audio type, such as audio/mpeg |
Cache-Control | Response. public, max-age=86400 for a public voice; private, max-age=300 for the rest |
Request
curl
curl https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/preview \
-H "Authorization: Bearer $ALBASTOV_API_KEY" \
--output preview.mp3Python
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/preview",
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}"},
)
with urllib.request.urlopen(req, timeout=30) as res, open("preview.mp3", "wb") as f:
f.write(res.read())JavaScript
import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/preview", {
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
await writeFile("preview.mp3", Buffer.from(await res.arrayBuffer()));Response
200 with the preview as the body; 206 for a byte range.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | voice_not_found | No saved voice with the id, or another account's private voice |
| 404 | preview_not_ready | The preview is not made yet; preview_url is set once the preview exists |
Copy a voice
POST /v1/media/voices/{id}/copy makes the account's own private copy of a voice the key may use: a new id, the
clip and preview copied, the category kept, the consent note left behind. The copy counts toward the voice quota.
Parameters
A JSON body, or no body.
| Name | Type | Description |
|---|---|---|
id required | string | The voice id vo_… to copy, in the path |
name | string | The copy's name, 1 to 100 characters; default the source voice's name |
Request
curl
curl -X POST https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/copy \
-H "Authorization: Bearer $ALBASTOV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "My narrator"}'Python
import json
import os
import urllib.request
req = urllib.request.Request(
"https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/copy",
data=json.dumps({"name": "My narrator"}).encode(),
headers={"User-Agent": "my-app/1.0", "Authorization": f"Bearer {os.environ['ALBASTOV_API_KEY']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as res:
print(json.load(res)["id"])JavaScript
const res = await fetch("https://api.albastov.com/v1/media/voices/vo_hVAiUiqrYyvgKKkBvgzHnh/copy", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.ALBASTOV_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "My narrator" }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).id);Response
201 with the new voice object: owner you, visibility private, usage_count 0.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | bad-param | A field other than name, or a name outside 1 to 100 characters |
| 400 | bad-json | The body is not a JSON object |
| 404 | voice_not_found | No saved voice with the id, or another account's private voice |
| 409 | voice_not_ready | A designed voice still pending, or failed |
| 422 | voice_quota | The account already has quota voices, 30 by default |