Skip to content

Voices

List, create, design, read, change, copy and delete saved voices, and fetch their clips.

/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

FieldTypeValue
idstringvo_ and 22 random characters; a built-in voice keeps its name, such as af_heart
objectstringvoice
namestring1 to 100 characters
descriptionstring0 to 500 characters; the description of a designed voice
categorystringcloned (from a clip), designed (from a description) or builtin (a model's own voice)
visibilitystringprivate, members or public
ownerstringyou for a voice of the key's account, albastov for every other voice
statusstringready; pending while a designed voice is made; failed
errorstring or nullWhy a designed voice failed
languagestringA language code such as en or en-gb, set by the voice's account
gender, accentstringFree text set by the voice's account
labelsobjectUp to 10 string keys and string values, such as {"use": "narration"}
duration_snumber or nullThe clip's length; null for a WebM clip and a built-in voice
transcriptstringThe words of the clip; filled on first use when empty
preview_urlstring or nullA short line spoken in the voice; null until made
preview_jobstring or nullThe job making the preview or the designed voice
audio_urlstring or nullThe reference clip; null for a built-in voice and a voice that is not ready
default_modelstring or nullThe model a speech request without model runs
modelsarrayEvery model that speaks the voice, recommended first
usage_countintegerJobs submitted with the voice, the preview excluded
created_at, updated_atstring or nullRFC 3339 times; null for a built-in voice
consentstringThe 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

NameTypeDescription
scopestringall (default): the account's voices and every shared voice; mine; shared: other accounts' shared voices
modelstringOnly voices the model speaks. The model's built-in voices come first on the first page and do not count toward limit
categorystringcloned, designed or builtin; builtin needs model
languagestringen matches en and every en-…
qstringText in the name or description, any case
limitinteger1 to 100, default 20
cursorstringnext_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

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

StatusCodeMeaning
400bad-paramscope, 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.

NameTypeDescription
file required in multipartfileWAV, 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 JSONstringAn upload of the account up_…, or a result of the account's job job:<id>/<i>
namestring1 to 100 characters. Required in JSON; in multipart, defaults to the file name without its extension
descriptionstring500 characters at most
languagestringA language code such as en, 35 characters at most
genderstring40 characters at most
accentstring60 characters at most
labelsobjectUp to 10 keys of 1 to 40 characters, string values of 100 at most; in multipart, the object as JSON text
transcriptstringThe words of the clip, 5,000 characters at most
consentstringWho 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.

Response
{
  "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

StatusCodeMeaning
400bad-paramA 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, ...)
400bad-uploadThe multipart body has no boundary or did not arrive whole
400bad-jsonThe JSON body is not an object
413too-largeThe clip is over 20 MB
415not-audioThe clip is not WAV, MP3, M4A, Ogg, WebM or FLAC
415multipart-or-jsonThe body is neither multipart nor JSON
422voice_quotaThe 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.

NameTypeDescription
name requiredstring1 to 100 characters
description requiredstringThe voice to make, such as "female, warm, low pitch, slow", 500 characters at most
modelstringomnivoice (default)
textstringThe line the voice speaks in its clip, 1,000 characters at most; default a fixed passage of about 15 s
languagestringA 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.

Response
{
  "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

StatusCodeMeaning
400bad-paramA field is missing, unknown or too long, or model does not design voices; param names the field
400bad-jsonThe body is not a JSON object
422voice_quotaThe account already has quota voices, 30 by default
429budget-exhaustedThe account's daily media time is used up; resets_at is the next 00:00 UTC
429too-many-openThe 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

NameTypeDescription
id requiredstringThe 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

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

StatusCodeMeaning
404voice_not_foundNo 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.

NameTypeDescription
id requiredstringThe voice id vo_…, in the path
namestring1 to 100 characters
descriptionstring500 characters at most
language, gender, accentstring35, 40 and 60 characters at most
labelsobjectReplaces the labels: up to 10 keys of 1 to 40 characters, string values of 100 at most
transcriptstringThe words of the clip, 5,000 characters at most
consentstringThe consent note, 2,000 characters at most
visibilitystringprivate, 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

StatusCodeMeaning
400bad-paramA field is unknown, not a string or too long, or visibility is not valid; param names the field
400bad-jsonThe body is not a JSON object
403visibility_owner_onlyAn account other than the owner account sets members or public
404voice_not_foundNo voice with the id on the key's account
422consent_requiredA 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

NameTypeDescription
id requiredstringThe 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

StatusCodeMeaning
404voice_not_foundNo 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

NameTypeDescription
id requiredstringThe voice id vo_…, in the path

Headers

NameDescription
RangeRequest. A byte range, such as bytes=0-1023; the answer is 206 with Content-Range
Content-TypeResponse. The clip's audio type, such as audio/mpeg or audio/wav
Cache-ControlResponse. 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.mp3

Python

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

StatusCodeMeaning
404voice_not_foundNo saved voice with the id, or another account's private voice
409voice_not_readyA 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

NameTypeDescription
id requiredstringThe voice id vo_…, in the path

Headers

NameDescription
RangeRequest. A byte range, such as bytes=0-1023; the answer is 206 with Content-Range
Content-TypeResponse. The preview's audio type, such as audio/mpeg
Cache-ControlResponse. 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.mp3

Python

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

StatusCodeMeaning
404voice_not_foundNo saved voice with the id, or another account's private voice
404preview_not_readyThe 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.

NameTypeDescription
id requiredstringThe voice id vo_… to copy, in the path
namestringThe 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

StatusCodeMeaning
400bad-paramA field other than name, or a name outside 1 to 100 characters
400bad-jsonThe body is not a JSON object
404voice_not_foundNo saved voice with the id, or another account's private voice
409voice_not_readyA designed voice still pending, or failed
422voice_quotaThe account already has quota voices, 30 by default