API and MCP server
The same use cases as the web app — voices, uploads, conversions, narrations and downloads — from your code or from your assistant. The same minutes and the same bill.
Authentication#
Every request carries an API key, either in its own header or as a bearer credential. Both forms work and do exactly the same thing; pick whichever suits your client.
x-api-key— An API key created in Settings → API (miy_…).bearer— The same API key, asAuthorization: Bearer miy_…. An OAuth 2.1 access token is NOT accepted on/v1: it authenticates the MCP server only.
Keys are created in Settings → API. The full key is shown once, when you create it: keep it where you keep your other secrets and never ship it in a client. Revoking one takes effect immediately and erases nothing you already did with it.
The MCP server does not use keys: it authorises with a token your assistant obtains for you. See the MCP server section.
What a key carries
When you create a key you choose its name, the permissions it carries, an optional expiry and, if you want one, a daily overage cap of its own below the general one. A key is never widened afterwards: one that needs more is a new key.
| Permissions you can grant it | voices:readjobs:readjobs:writenarrations:write |
|---|---|
| Maximum length of the name, in characters | 64 |
| Expiry, optional, in days | 1–365 |
| Daily overage cap of the key, optional, in minutes | 1–1,440 |
Access is a right of your plan#
The API and the MCP server are included in the professional plans. If yours does not include them the credential still exists but does not authorise: the answer says so with its own code and a forbidden status.
Losing the plan deletes and revokes nothing. Your keys and your authorisations stay where they were and start working again as soon as the plan does.
The API and the MCP server are part of the service: what you do through them is covered by the same terms and the same privacy policy as your account.
Limits#
Requests are limited per minute, both per calling address and per credential. When you cross a limit, the answer comes back with a too-many-requests status and a header saying how many seconds to wait. Honour it instead of retrying in a loop.
There is also a daily overage cap per credential, in seconds and per calendar day. Once you reach it, an operation that would consume is refused without writing anything: no job is created, no overage is charged, and the next day starts from zero.
Every answer from an operation that consumes carries the estimated split between what comes out of your plan and what would be overage. It is an estimate made up front, not the invoice: the final figure is settled when the job finishes.
| Requests per minute from one calling address | 300 |
|---|---|
| Requests per minute with one credential | 120 |
| Daily overage cap per credential, unless the key sets a lower one | 30 minutes |
| When the daily cap starts over | at midnight, UTC |
| Header that says how many seconds to wait on a too-many-requests answer | Retry-After |
Idempotency#
Operations that create work require an idempotency key that you choose and that must be different for every new attempt. On uploads it is optional, but worth sending.
If a request is cut short by a network failure and you do not know whether it arrived, repeat it with the SAME key: you either get back the work that was already created or it is created then, never twice.
Pagination#
Lists are walked with an opaque cursor: ask for the first page, keep the cursor the answer carries and pass it back untouched on the next request. Do not parse it or build one: what is inside is an internal detail and may change.
When the cursor in the answer comes back empty you are done: there are no more pages.
Errors#
Every error, in the API and in the MCP server, arrives in the same shape: a code, a message and a request identifier.
The code is stable and is what your program should read. The full catalogue is below, with the status each one carries.
The message is written for a person and follows the language you ask for in the language header; English when you ask for none. Do not branch on it: change the language and it can be rewritten.
Keep the request identifier. It is the first thing we will ask for if you write to support, and it is what lets us find your exact call among all of them.
If an answer does not add up, write to us with the request identifier at support@makeityours.app, or open the help channel inside the app: Help
The message can come in any of the languages the product speaks; ask for one with the language header using one of these codes:
esenpt-BRdefritpl
Versioning#
The version lives in the path and never changes meaning under you: we do not remove a field, narrow a value or change what an answer means without changing the version.
What we do add — a new operation, a new optional field, a new error code — is announced in the changelog. Anything incompatible would ship under a different version.
Quick start#
From a file on your disk to a downloaded result, in seven steps. Every example comes from the contract: the method, the path, the body and the answer are the ones the real API serves. Replace the key and the idempotency key with your own.
Step 1createUpload
Register the upload and get the temporary address to put the file at, with its limits and its expiry.
bashcurl -X POST 'https://www.makeityours.app/api/v1/uploads' \ -H 'x-api-key: miy_…' \ -H 'Idempotency-Key: <unique-key>' \ -H 'Content-Type: application/json' \ --data '{ "filename": "spring-spot.mp4", "sizeBytes": 48213907, "contentType": "video/mp4" }'Requestjson{ "filename": "spring-spot.mp4", "sizeBytes": 48213907, "contentType": "video/mp4" }Response · 201json{ "data": { "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6", "putUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/source.mp4?signature=example", "expiresAt": "2026-09-13T11:00:00.000Z", "constraints": { "maxSizeBytes": 524288000, "maxDurationSeconds": 180, "containers": [ "mp4", "mov" ] } } }Step 2
Upload the file to that address in a single request, declaring the same content type you registered it with: it is the type the stored file keeps and the one the download answers with. The request carries none of your credentials: the authorisation travels inside the address itself.
bashcurl -X PUT 'https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/source.mp4?signature=example' \ -H 'Content-Type: video/mp4' \ --upload-file 'spring-spot.mp4'Step 3runUploadPreflight
Ask for the pre-flight check of the uploaded file. That is what measures the duration and decides whether it can be converted.
bashcurl -X POST 'https://www.makeityours.app/api/v1/uploads/u8p2l4o6a1d3i5n7g9s2t4r6/preflight' \ -H 'x-api-key: miy_…'Response · 200json{ "data": { "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6", "ready": true, "preflight": { "container": "mov,mp4,m4a,3gp,3g2,mj2", "videoCodec": "h264", "hasAudio": true, "audioCodec": "aac", "containerDurationS": 41.72, "audioDurationS": 41.68, "vfrSuspected": false, "remuxSafe": true } } }Step 4createJob
Confirm the conversion with the voices you want. This is where the minutes are reserved, so this is where the idempotency key is required.
bashcurl -X POST 'https://www.makeityours.app/api/v1/jobs' \ -H 'x-api-key: miy_…' \ -H 'Idempotency-Key: <unique-key>' \ -H 'Content-Type: application/json' \ --data '{ "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6", "voiceIds": [ "v4n8k2m6p1q9s3t7w5x1y2z4", "l1a3m5v7o9i2c4e6b8d0f2h4" ], "name": "Spring spot — Ana and Liam", "voiceSettings": { "v4n8k2m6p1q9s3t7w5x1y2z4": { "similarity": 0.8, "stability": 0.45, "speakerBoost": true } } }'Requestjson{ "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6", "voiceIds": [ "v4n8k2m6p1q9s3t7w5x1y2z4", "l1a3m5v7o9i2c4e6b8d0f2h4" ], "name": "Spring spot — Ana and Liam", "voiceSettings": { "v4n8k2m6p1q9s3t7w5x1y2z4": { "similarity": 0.8, "stability": 0.45, "speakerBoost": true } } }Response · 202json{ "data": { "job": { "id": "k7f2q9x1m3p8w5z2c4v6b8n0", "status": "queued", "steps": [ { "key": "prepare", "done": false }, { "key": "separate", "done": false }, { "key": "convert", "done": false }, { "key": "package", "done": false }, { "key": "finalize", "done": false } ], "variants": [ { "variantId": "r3v5a7r9i1a3n5t7x9y1z3w5", "voiceName": "Ana", "driftMs": null, "hasResult": false, "speakers": [] }, { "variantId": "r4v6a8r0i2a4n6t8x0y2z4w6", "voiceName": "Liam", "driftMs": null, "hasResult": false, "speakers": [] } ], "variantsTotal": 2, "convertVariantsDone": 0, "paused": false, "isRemaster": false, "isNarration": false }, "estimate": { "planSeconds": 66, "overageSeconds": 18, "overageAmountCents": 18, "currency": "usd" } } }Step 5
If you have an endpoint registered, you are told the job finished as soon as it does, signed. It is the alternative to polling.
bashcurl -X POST 'https://example.com/webhooks' \ -H 'MIY-Signature: t=1789293852,v1=<hex HMAC-SHA256 of "t.body" with your endpoint secret>' \ -H 'Content-Type: application/json' \ --data '{ "id": "e5v7e9n1t3a5b7c9d1e3f5g7", "type": "job.completed", "createdAt": "2026-09-13T10:04:12.000Z", "data": { "jobId": "k7f2q9x1m3p8w5z2c4v6b8n0", "status": "done", "kind": "conversion", "links": { "job": "https://www.makeityours.app/api/v1/jobs/k7f2q9x1m3p8w5z2c4v6b8n0", "download": "https://www.makeityours.app/api/v1/jobs/k7f2q9x1m3p8w5z2c4v6b8n0/download" } } }'httpt=1789293852,v1=<hex HMAC-SHA256 of "t.body" with your endpoint secret>Requestjson{ "id": "e5v7e9n1t3a5b7c9d1e3f5g7", "type": "job.completed", "createdAt": "2026-09-13T10:04:12.000Z", "data": { "jobId": "k7f2q9x1m3p8w5z2c4v6b8n0", "status": "done", "kind": "conversion", "links": { "job": "https://www.makeityours.app/api/v1/jobs/k7f2q9x1m3p8w5z2c4v6b8n0", "download": "https://www.makeityours.app/api/v1/jobs/k7f2q9x1m3p8w5z2c4v6b8n0/download" } } }Step 6getJob
Read the job whenever you want to know how it is going or which variants it produced.
bashcurl -X GET 'https://www.makeityours.app/api/v1/jobs/j2b4d6f8h0k1m3p5r7t9v1x3' \ -H 'x-api-key: miy_…'Response · 200json{ "data": { "id": "j2b4d6f8h0k1m3p5r7t9v1x3", "status": "done", "steps": [ { "key": "prepare", "done": true }, { "key": "separate", "done": true }, { "key": "convert", "done": true }, { "key": "package", "done": true }, { "key": "finalize", "done": true } ], "variants": [ { "variantId": "r3v5a7r9i1a3n5t7x9y1z3w5", "voiceName": "Ana", "driftMs": 120, "hasResult": true, "speakers": [] } ], "variantsTotal": 1, "convertVariantsDone": 1, "paused": false, "isRemaster": false, "isNarration": false } }Step 7downloadJob
Ask for the download with your key and read the redirect header: it points at a temporary address to fetch the file from. Fetch the file with a second command WITHOUT the key — the address carries its own authorisation and expires quickly — and do not let your client follow the redirect while re-sending the key to another server.
bashcurl -X GET 'https://www.makeityours.app/api/v1/jobs/j2b4d6f8h0k1m3p5r7t9v1x3/download' \ -H 'x-api-key: miy_…' \ -sS -o /dev/null -D -Then, without the keybashcurl -sS -o result.mp4 '<the Location header of the previous answer>'
The whole flow in one program
The same seven steps as one runnable file, in Node.js and in Python, with no dependency beyond the standard library. Each one reads your key from an environment variable, takes the video and the voice as arguments, waits for the job and saves the result next to it. Start from here and keep what you need.
// MakeItYours API - one complete integration: register an upload, PUT the video,
// run the pre-flight, confirm the conversion, wait for it and download the result.
// Node.js 20 or newer, standard library only (global fetch).
//
// usage: MIY_API_KEY=miy_… node convert.js <video.mp4|.mov> <voiceId>
//
// Pick a voiceId from GET /v1/voices (the shared bank, or ?kind=own for your clones).
const { randomUUID } = require('crypto');
const { readFile, writeFile } = require('fs/promises');
const { basename, extname } = require('path');
const API = 'https://www.makeityours.app/api';
const API_KEY = process.env.MIY_API_KEY;
const POLL_SECONDS = 5;
// A job never leaves one of these: the loop below stops on them.
const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled']);
// The contentType declared in step 1. The PUT of step 2 MUST send the same value.
const CONTENT_TYPES = { '.mp4': 'video/mp4', '.mov': 'video/quicktime' };
class ApiError extends Error {
constructor(status, envelope) {
const error = envelope && envelope.error ? envelope.error : {};
super(`${status} ${error.code || 'unknown'}: ${error.message || ''} (requestId ${error.requestId || '?'})`);
this.status = status;
this.code = error.code;
this.requestId = error.requestId;
}
}
// One call to the API: the key, the optional idempotency key, an optional JSON body.
// Any 4xx/5xx answer becomes an ApiError carrying the code and the requestId of the envelope.
async function call(method, path, { body, idempotencyKey, redirect = 'follow' } = {}) {
const headers = { 'x-api-key': API_KEY };
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(API + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
redirect,
});
if (response.status >= 400) throw new ApiError(response.status, await response.json().catch(() => null));
return response;
}
const json = (response) => response.json();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main(file, voiceId) {
if (!API_KEY || !file || !voiceId) {
console.error('usage: MIY_API_KEY=miy_… node convert.js <video.mp4|.mov> <voiceId>');
process.exit(2);
}
const bytes = await readFile(file);
const contentType = CONTENT_TYPES[extname(file).toLowerCase()];
if (!contentType) throw new Error('only .mp4 and .mov files are accepted');
// ONE idempotency key for the upload AND the job that confirms it (see "Idempotency").
const idempotencyKey = randomUUID();
// 1. Register the upload: the answer carries the presigned PUT URL and the limits.
const upload = await json(await call('POST', '/v1/uploads', {
body: { filename: basename(file), sizeBytes: bytes.byteLength, contentType },
idempotencyKey,
}));
const { uploadId, putUrl } = upload.data;
// 2. PUT the bytes. No API key here (the URL carries its own authorisation) and
// the SAME Content-Type as step 1: it is the media type the stored file keeps.
const put = await fetch(putUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: bytes });
if (!put.ok) throw new Error(`upload failed with status ${put.status}`);
// 3. Pre-flight: measures the duration and decides whether the file can be converted.
await call('POST', '/v1/uploads/' + uploadId + '/preflight');
// 4. Confirm the conversion. Minutes are reserved HERE; the estimate says how many.
const created = await json(await call('POST', '/v1/jobs', {
body: { uploadId, voiceIds: [voiceId], name: basename(file) },
idempotencyKey,
}));
let job = created.data.job;
const { planSeconds, overageSeconds } = created.data.estimate;
console.log(`job ${job.id}: ${job.status} (estimate: ${planSeconds} s from the plan, ${overageSeconds} s of overage)`);
// 5. Wait. In production a webhook (see "Webhooks") replaces this loop.
while (!TERMINAL_STATUSES.has(job.status)) {
await sleep(POLL_SECONDS * 1000);
job = (await json(await call('GET', '/v1/jobs/' + job.id))).data;
console.log(`job ${job.id}: ${job.status}`);
}
if (job.status !== 'done') throw new Error(`the job ended ${job.status}`);
// 6. Download: the API answers a redirect to a short-lived URL. Read the Location
// WITHOUT following it, then fetch that URL WITHOUT the key.
const redirect = await call('GET', '/v1/jobs/' + job.id + '/download', { redirect: 'manual' });
const location = redirect.headers.get('location');
if (!location) throw new Error(`expected a redirect, got status ${redirect.status}`);
const result = await fetch(location);
if (!result.ok) throw new Error(`download failed with status ${result.status}`);
await writeFile('result.mp4', Buffer.from(await result.arrayBuffer()));
console.log('saved result.mp4');
}
main(process.argv[2], process.argv[3]).catch((error) => {
console.error(error instanceof ApiError ? `API error: ${error.message}` : error);
process.exit(1);
});"""MakeItYours API - one complete integration: register an upload, PUT the video,
run the pre-flight, confirm the conversion, wait for it and download the result.
Python 3.9 or newer, standard library only.
usage: MIY_API_KEY=miy_… python3 convert.py <video.mp4|.mov> <voiceId>
Pick a voiceId from GET /v1/voices (the shared bank, or ?kind=own for your clones).
"""
import json
import os
import sys
import time
import uuid
from urllib import error, request
API = "https://www.makeityours.app/api"
API_KEY = os.environ.get("MIY_API_KEY", "")
POLL_SECONDS = 5
# A job never leaves one of these: the loop below stops on them.
TERMINAL_STATUSES = {"done", "failed", "cancelled"}
# The contentType declared in step 1. The PUT of step 2 MUST send the same value.
CONTENT_TYPES = {".mp4": "video/mp4", ".mov": "video/quicktime"}
class ApiError(Exception):
"""A 4xx/5xx answer: the code and the requestId of the envelope."""
def __init__(self, status, envelope):
err = (envelope or {}).get("error") or {}
super().__init__(
f"{status} {err.get('code', 'unknown')}: {err.get('message', '')} (requestId {err.get('requestId', '?')})"
)
self.status = status
self.code = err.get("code")
self.request_id = err.get("requestId")
class _NoRedirect(request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def call(method, path, body=None, idempotency_key=None, follow_redirects=True):
"""One call to the API: the key, the optional idempotency key, an optional JSON body."""
headers = {"x-api-key": API_KEY}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode("utf-8")
req = request.Request(API + path, data=data, method=method, headers=headers)
opener = request.build_opener() if follow_redirects else request.build_opener(_NoRedirect)
try:
with opener.open(req) as response:
return response.status, response.headers, response.read()
except error.HTTPError as exc:
payload = exc.read()
if 300 <= exc.code < 400:
return exc.code, exc.headers, payload
try:
envelope = json.loads(payload)
except ValueError:
envelope = None
raise ApiError(exc.code, envelope) from None
def call_json(method, path, **kwargs):
_, _, payload = call(method, path, **kwargs)
return json.loads(payload)
def main(file, voice_id):
if not API_KEY or not file or not voice_id:
print("usage: MIY_API_KEY=miy_… python3 convert.py <video.mp4|.mov> <voiceId>", file=sys.stderr)
sys.exit(2)
with open(file, "rb") as handle:
data = handle.read()
content_type = CONTENT_TYPES.get(os.path.splitext(file)[1].lower())
if not content_type:
raise SystemExit("only .mp4 and .mov files are accepted")
# ONE idempotency key for the upload AND the job that confirms it (see "Idempotency").
idempotency_key = str(uuid.uuid4())
# 1. Register the upload: the answer carries the presigned PUT URL and the limits.
upload = call_json(
"POST",
"/v1/uploads",
body={"filename": os.path.basename(file), "sizeBytes": len(data), "contentType": content_type},
idempotency_key=idempotency_key,
)
upload_id, put_url = upload["data"]["uploadId"], upload["data"]["putUrl"]
# 2. PUT the bytes. No API key here (the URL carries its own authorisation) and
# the SAME Content-Type as step 1: it is the media type the stored file keeps.
put = request.Request(put_url, data=data, method="PUT", headers={"Content-Type": content_type})
with request.urlopen(put) as response:
if response.status >= 300:
raise SystemExit(f"upload failed with status {response.status}")
# 3. Pre-flight: measures the duration and decides whether the file can be converted.
call("POST", "/v1/uploads/" + upload_id + "/preflight")
# 4. Confirm the conversion. Minutes are reserved HERE; the estimate says how many.
created = call_json(
"POST",
"/v1/jobs",
body={"uploadId": upload_id, "voiceIds": [voice_id], "name": os.path.basename(file)},
idempotency_key=idempotency_key,
)
job = created["data"]["job"]
estimate = created["data"]["estimate"]
print(f"job {job['id']}: {job['status']} (estimate: {estimate['planSeconds']} s from the plan, {estimate['overageSeconds']} s of overage)")
# 5. Wait. In production a webhook (see "Webhooks") replaces this loop.
while job["status"] not in TERMINAL_STATUSES:
time.sleep(POLL_SECONDS)
job = call_json("GET", "/v1/jobs/" + job["id"])["data"]
print(f"job {job['id']}: {job['status']}")
if job["status"] != "done":
raise SystemExit(f"the job ended {job['status']}")
# 6. Download: the API answers a redirect to a short-lived URL. Read the Location
# WITHOUT following it, then fetch that URL WITHOUT the key.
status, headers, _ = call("GET", "/v1/jobs/" + job["id"] + "/download", follow_redirects=False)
location = headers.get("Location")
if not location:
raise SystemExit(f"expected a redirect, got status {status}")
with request.urlopen(location) as result, open("result.mp4", "wb") as out:
out.write(result.read())
print("saved result.mp4")
if __name__ == "__main__":
try:
main(sys.argv[1] if len(sys.argv) > 1 else None, sys.argv[2] if len(sys.argv) > 2 else None)
except ApiError as exc:
print(f"API error: {exc}", file=sys.stderr)
sys.exit(1)Voices#
There are two families of voices: the bank, shared by everyone, and your own. The answer says which family each one belongs to, and only you ever see yours.
Every voice carries a link to a sample so you can listen before spending minutes. Reading one of your own active voices for the first time GENERATES its sample during that very request — a real reading of the voice, made once and kept — so the answer takes a moment and already carries the link.
An empty sample means the voice is not active, or that generating it failed. Nothing is lost: the answer is still complete, and a later request tries again once a short wait has passed.
The script read in that sample is in the language of the account that owns the voice; when the account has none set, in the language you ask for in the language header; and in Spanish when neither says. Because the sample is made once and kept, the language in force at the request that generates it is the one that sticks.
Bank facets
The bank is filtered by these facets. The codes never change; what changes with the language is the word you read.
- language (Facet)
arArabicdeGermanenEnglishesSpanishfrFrenchhiHindiitItalianjaJapanesekoKoreannlDutchplPolishptPortugueseroRomaniansvSwedishtrTurkishzhChinese- accent (Facet)
americanAmericanandalusianAndalusianargentineArgentineaustralianAustralianaustrianAustrianbavarianBavarianbelgianBelgianbrazilianBrazilianbritishBritishcanadianCanadiancastilianCastiliancatalanCatalanchileanChileancolombianColombiancubanCubaneuropeanEuropeanfilipinoFilipinofrenchFrenchgalicianGaliciangermanGermanindianIndianirishIrishitalianItalianjamaicanJamaicanlatin-americanLatin AmericanmexicanMexicanneutralneutralnew-zealandNew ZealandnigerianNigerianparisianParisianperuvianPeruvianpuerto-ricanPuerto RicanquebecoisQuebecoisscottishScottishsingaporeanSingaporeansouth-africanSouth AfricanspanishSpanishstandardstandardswedishSwedishswissSwisstransatlanticTransatlantictuscanTuscanus-southernUS SouthernvenezuelanVenezuelanwelshWelsh- age (Facet)
youngyoungadultadultseniorsenior- gender (Facet)
femalefemalemalemaleneutralneutral- useCase (Facet)
advertisementadvertisingasmrASMRaudiobookaudiobookscharacterscharactersconversationalconversationeducationaleducational contententertainmententertainmentmeditationmeditationnarrationnarrationnewsnewssocial-mediasocial media
Conversions#
Creating a conversion returns a job with a status you read until it finishes. The possible statuses travel in the schema, and they are the same ones you see in the web app.
A job walks these statuses in this order. The last three are final: once there, it never changes again.
confirmedqueuedprocessingdone(final)failed(final)cancelled(final)
If you poll instead of receiving a webhook, read the job no more often than this: 5 seconds
Cancelling is an order, not an immediate effect: the request is accepted and the cancellation happens afterwards. Read the job again to find out whether it arrived in time. If it is cancelled before finishing, the reserved minutes go back to your balance.
Several voices in one video#
When several characters talk in your video, each one can sound with its own voice. The upload is analysed to tell the speakers apart, and the conversion takes a voice for each speaker.
- After the pre-flight of the upload, request the speaker detection. It runs in the background, and requesting it again changes nothing.
- Read the detection until it is no longer running. Once it is ready, each speaker comes with the stretches in which it talks and a short clip of its own voice to recognise it.
- Create the conversion from that upload saying, for each variant, which voice each speaker takes. The first speaker always keeps the variant voice, and so does any speaker you leave out.
A detection walks these statuses; every one but the first is final:
pendingready(final)single(final)too_many(final)failed(final)
The detection consumes no minutes, and a conversion with several voices costs exactly what it costs with one: what counts is the length of the video, not the number of voices.
If the several-voices feature is not available to your account at that moment, or the detection is not ready, the conversion is refused with its code: it is never converted with one voice instead. If a detection is still running after a long while, convert with one voice.
A conversion that reuses the video of another one has no detection of its own: for several voices, upload the video again.
{
"data": {
"speakers": [
{
"index": 0,
"previewUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/speaker-0.m4a?signature=example",
"segments": [
{
"endMs": 12300,
"startMs": 0
},
{
"endMs": 41680,
"startMs": 28640
}
],
"totalMs": 25340
},
{
"index": 1,
"previewUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/speaker-1.m4a?signature=example",
"segments": [
{
"endMs": 28640,
"startMs": 12300
}
],
"totalMs": 16340
}
],
"status": "ready",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}{
"name": "Spring spot — two characters",
"speakers": [
{
"assignments": [
{
"speaker": 1,
"voiceId": "l1a3m5v7o9i2c4e6b8d0f2h4"
}
],
"variantVoiceId": "v4n8k2m6p1q9s3t7w5x1y2z4"
}
],
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6",
"voiceIds": [
"v4n8k2m6p1q9s3t7w5x1y2z4"
]
}The MCP server still converts with one voice per variant: several voices is, for now, an API feature.
Download#
The same operation serves the converted video, its audio extract, one separated track or the narration: which one is a query parameter, documented in the reference below, and the video is what you get when you say nothing.
When a job has several variants — because you asked for several voices or several tracks — you have to say which one you want: without it there is no right answer and the request is refused. Reading the job tells you which ones exist.
Every file you download carries the marking for content generated with artificial intelligence that European law requires. Removing it is forbidden by the terms of service.
Narrations#
Narrating a script with one of your usable voices creates a job just like a conversion: you read it and download it through the same operations.
The answer says the request has been ACCEPTED, not that the job is already queued: queuing happens right afterwards. If the answer never reaches you, or you doubt it was registered, repeat the request with the same idempotency key; it will not be duplicated.
Webhooks#
If you would rather not poll a job for its status, register an endpoint in Settings and we will tell you when it finishes. The list of events and the exact shape of each one are below.
Deliveries do not come from a fixed set of addresses, so do not allow-list by source: the signature is what proves a delivery is ours.
job.completed
A job finished successfully
Delivered to every enabled endpoint of yours subscribed to job.completed. Verify MIY-Signature (t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with your endpoint secret>, tolerance 300 s) before trusting the body. Answer any 2xx; anything else is retried — 8 attempts in all, waiting 1 min, 5 min, 15 min, 1 h, 4 h, 12 h and 24 h between them — and given up after the last one.
MIY-Signature— Signature of the delivery:t=<unix seconds>,v1=<hex>.
{
"createdAt": "2026-09-13T10:04:12.000Z",
"data": {
"jobId": "k7f2q9x1m3p8w5z2c4v6b8n0",
"kind": "conversion",
"links": {
"download": "https://www.makeityours.app/api/v1/jobs/k7f2q9x1m3p8w5z2c4v6b8n0/download",
"job": "https://www.makeityours.app/api/v1/jobs/k7f2q9x1m3p8w5z2c4v6b8n0"
},
"status": "done"
},
"id": "e5v7e9n1t3a5b7c9d1e3f5g7",
"type": "job.completed"
}job.failed
A job failed or was cancelled
Delivered to every enabled endpoint of yours subscribed to job.failed. Verify MIY-Signature (t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with your endpoint secret>, tolerance 300 s) before trusting the body. Answer any 2xx; anything else is retried — 8 attempts in all, waiting 1 min, 5 min, 15 min, 1 h, 4 h, 12 h and 24 h between them — and given up after the last one.
MIY-Signature— Signature of the delivery:t=<unix seconds>,v1=<hex>.
{
"createdAt": "2026-09-13T10:04:12.000Z",
"data": {
"jobId": "f4a6i8l0e2d4j6o8b0c2d4e6",
"kind": "conversion",
"links": {
"download": null,
"job": "https://www.makeityours.app/api/v1/jobs/f4a6i8l0e2d4j6o8b0c2d4e6"
},
"status": "failed"
},
"id": "e6v8e0n2t4a6b8c0d2e4f6g8",
"type": "job.failed"
}Verifying the signature
The header may carry more than one digest while you rotate the secret: accept the delivery if any of them matches.
const { createHmac, timingSafeEqual } = require('node:crypto');
const SIGNATURE_HEADER = 'MIY-Signature';
const SCHEME = 'v1';
const TOLERANCE_SECONDS = 300;
// `header` is the value of SIGNATURE_HEADER. `body` MUST be the raw payload you
// received, as a string: re-serialising the parsed JSON changes the bytes, and a
// different byte is a different signature.
function verify(header, body, secret, nowSeconds) {
if (typeof header !== 'string' || header === '') return false;
// `t` is kept TWICE: as the header wrote it (the bytes the sender signed over)
// and as a number (for the clock). Re-serialising it would be the same mistake
// as re-serialising the body.
let timestampText = null;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const eq = part.indexOf('=');
if (eq <= 0) continue;
const name = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (name === 't') {
if (!/^[0-9]{1,15}$/.test(value)) return false;
timestampText = value;
timestamp = Number(value);
} else if (name === SCHEME) {
// Several entries are allowed while a secret is being rotated.
signatures.push(value);
}
}
if (timestamp === null || signatures.length === 0) return false;
const expected = createHmac('sha256', secret).update(timestampText + '.' + body).digest('hex');
const expectedBytes = Buffer.from(expected, 'hex');
const matches = signatures.some((candidate) => {
const candidateBytes = Buffer.from(candidate, 'hex');
if (candidateBytes.length !== expectedBytes.length) return false;
return timingSafeEqual(candidateBytes, expectedBytes);
});
if (!matches) return false;
// Signature first, clock second: a tampered body is a forgery, a well-signed
// old one is merely late — and only the second is worth a different log line.
return Math.abs(nowSeconds - timestamp) <= TOLERANCE_SECONDS;
}
module.exports = { verify };import hashlib
import hmac
SIGNATURE_HEADER = "MIY-Signature"
SCHEME = "v1"
TOLERANCE_SECONDS = 300
def verify(header, body, secret, now_seconds):
"""`body` is the raw payload as bytes. Never re-serialise the parsed JSON."""
if not header:
return False
# `t` is kept twice: as the header wrote it (the bytes the sender signed
# over) and as a number (for the clock). Re-serialising it would be the same
# mistake as re-serialising the body.
timestamp_text = None
timestamp = None
signatures = []
for part in header.split(","):
name, _, value = part.partition("=")
name, value = name.strip(), value.strip()
if name == "t":
# `str.isdigit()` alone accepts Arabic-Indic and other non-ASCII
# digits, which `int()` then happily parses: the Node twin bounds
# the same field with `[0-9]{1,15}` and this has to agree.
if not (value.isascii() and value.isdigit()) or len(value) > 15:
return False
timestamp_text = value
timestamp = int(value)
elif name == SCHEME:
# Several entries are allowed while a secret is being rotated.
signatures.append(value)
if timestamp is None or not signatures:
return False
expected = hmac.new(
secret.encode("utf-8"),
(timestamp_text + ".").encode("utf-8") + body,
hashlib.sha256,
).hexdigest()
# `compare_digest` RAISES on two `str` with a non-ASCII character in them,
# and `candidate` comes straight from the request: a stray byte in the header
# must be a rejected delivery, not a crashed handler. The Node twin reaches the
# same verdict through `Buffer.from(candidate, "hex")`.
if not any(
candidate.isascii() and hmac.compare_digest(candidate, expected)
for candidate in signatures
):
return False
# Signature first, clock second.
return abs(now_seconds - timestamp) <= TOLERANCE_SECONDSRetries
Answer with any success status and answer fast: queue the work on your side and reply, do not process before replying. Anything else — or nothing — makes us retry with these waits, and after the last one the delivery is given up.
| Attempt | Wait before the next one |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 4 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 | None |
When several attempts in a row fail we email you, once, so an endpoint that went down does not stay down unnoticed. Delivering again clears it.
A delivery to an endpoint you have disabled, or made while your plan does not include the API, is neither attempted nor given up: it is held, re-examined periodically, and delivered once the endpoint is enabled or the plan is back. Deliveries that reached their last attempt are kept for a while so you can see what failed.
From Settings you can send yourself a test event. It is signed exactly like a real one, but it is NOT part of the contract: its type is not in the list of events and you should not write logic that depends on it.
MCP server#
The MCP server lets an assistant use your account on your behalf: list voices, start an upload, create a conversion or a narration and read how it is going. They are the same operations as the API, with the same plan, limit and idempotency rules.
https://www.makeityours.app/api/mcphttps://www.makeityours.app/.well-known/oauth-protected-resourcevoices:readjobs:readjobs:writenarrations:writewebhooks:manageoffline_access
webhooks:manage— reserved: no operation or tool requires it yet.
Adding it to your assistant
Assistants that speak this protocol are all set up the same way: add a remote server with its address and authorise in the browser. Menu names move between versions; the path is always this one.
The same path in every assistant
- Open the assistant settings and find the section for connectors or external servers.
- Choose to add a remote server (not a local one and not a command).
- Paste the server address shown above.
- Accept when it sends you to the browser to sign in and authorise.
- Go back to the assistant and check that the tools show up in its list.
Claude
In Claude, on the web and in the desktop app, the server is a custom connector: open the settings, find the connectors section, choose to add a custom connector and paste the address.
In Claude Code one command adds it; the /mcp command then opens the browser to authorise it:
claude mcp add --transport http makeityours https://www.makeityours.app/api/mcpCursor
In Cursor the server goes in the MCP configuration file, in the project or in your home directory. Paste this; Cursor sends you to the browser the first time it connects:
{
"mcpServers": {
"makeityours": {
"url": "https://www.makeityours.app/api/mcp"
}
}
}ChatGPT
In ChatGPT, custom servers are added as connectors from the settings, in the section for developers; the exact menu depends on your plan. Paste the address and authorise in the browser.
The authorisation, from the inside
Nothing has to be registered up front: the assistant registers itself the first time, and the authorisation uses the challenge exchange the protocol requires. All you see is the consent screen in the browser, with the client name and the permissions it asks for.
If the assistant also asks for the long-lived access permission, it gets a refresh credential and does not have to send you back to the browser every hour.
The code exchange has to state which resource the token is for. A client older than that part of the standard cannot use this server: it will receive a token the server does not accept. If your assistant fails there, update it.
Assistants that ask for the long-lived access permission also ask for the consent screen every time (the protocol client does that on its own), so you see which permissions are being requested on every new authorisation, including one for a client you had already authorised. A client that does not ask for it reuses an authorisation you already gave to that same client for the same permissions, without showing the screen again.
Authorisations are revoked in Settings → API → Authorisations, and the cut is immediate for every credential of that client.
Every tool states in its description that what it produces is synthetic content, marked as such.
Available tools
Each tool wraps one API operation and requires the same permission it does.
list_voicesList voices
Lists the available voices: the voice bank or the holder’s own voices, paginated by cursor. The result is synthetic content, marked as such (AI Act).
List the voice bank or your own voices.
listVoicesScope: voices:readIdempotency key: No
Result
get_voiceGet a voice
Returns one voice with its sample link, from the bank or the holder’s own. The result is synthetic content, marked as such (AI Act).
Get one voice with a sample link.
getVoiceScope: voices:readIdempotency key: No
Result
list_jobsList conversions
Lists the holder’s conversions and narrations, newest first, paginated by cursor. Uploads that were never confirmed are not listed. The result is synthetic content, marked as such (AI Act).
List your jobs.
listJobsScope: jobs:readIdempotency key: No
Result
get_jobGet a conversion
Returns the state of one of the holder’s conversions or narrations and its variants. The result is synthetic content, marked as such (AI Act).
Get the state of a job.
getJobScope: jobs:readIdempotency key: No
Result
create_conversionCreate a conversion
Confirms a conversion from an upload or from a previous job of the holder, with up to three voices. Runs the upload’s pre-flight itself if it has not been run yet. Consumes plan minutes exactly as the web app does and returns the estimated plan/overage split. Requires an idempotency key. The result is synthetic content, marked as such (AI Act).
Create a conversion (runs the pre-flight first if needed).
createJobScope: jobs:writeIdempotency key: Yes
Result
Possible errors
rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestnot_foundidempotency_key_requiredidempotency_conflictapi_daily_overage_capupload_not_readynot_confirmableinvalid_stateinvalid_voicesinvalid_tuneoverage_unavailable_currencyvoice_service_pausedaccount_closingsubscription_pausedenqueue_failedunavailablerejectednot_configuredpreflight_unavailable
create_narrationCreate a narration
Narrates a script with one of the holder’s usable voices and returns the job to follow and download. Consumes plan minutes exactly as the web app does. Requires an idempotency key. The result is synthetic content, marked as such (AI Act).
Create a narration.
createNarrationScope: narrations:writeIdempotency key: Yes
Result
Possible errors
rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestnot_foundidempotency_key_requiredidempotency_conflictapi_daily_overage_capnarration_disabledinvalid_scriptvoice_not_usableoverage_unavailable_currencyvoice_service_pausedaccount_closingsubscription_pausedenqueue_failedunavailable
get_download_urlGet the download URL
Returns a short-lived download URL of the result (video, audio extract, a single track or the narration) and when it expires. It does not redirect. The result is synthetic content, marked as such (AI Act).
Get a short-lived download URL of a result (no redirect).
downloadJobScope: jobs:readIdempotency key: No
Result
dataRequiredDownloadUrlResult
create_uploadStart an upload
Starts an upload and returns the URL to PUT the file to, its limits and when it expires. Then call create_conversion with the uploadId. The result is synthetic content, marked as such (AI Act).
Start an upload and get the PUT URL.
createUploadScope: jobs:writeIdempotency key: Yes
Result
Reference#
Generated from the contract: what is here is exactly what the API validates and answers.
1.1.0Base address: https://www.makeityours.app/apivoices:readjobs:readjobs:writenarrations:writewebhooks:manage
webhooks:manage— reserved: no operation or tool requires it yet.
https://www.makeityours.app/api/openapi.jsonvoices
GET/v1/voices
List voices
Lists the shared voice bank (default) or your own cloned voices (kind=own). Every item carries kind: a bank voice is the full Voice shape, one of your clones is the smaller OwnVoice (id, name, status). Bank filters take the stable codes served by GET /v1/voices/facets. The bank is paginated by cursor, newest first, and may change between pages: a voice retired meanwhile simply stops appearing, and a voice added to the bank while you page sits above every cursor, so it shows up on your next full pass, not in the current one. A nextCursor is bound to the FILTERS it was minted under, so replaying it with a different set of filters answers 400 invalid_cursor instead of paging a different sequence. With kind=own the list is your live clones (active and recycled) returned COMPLETE in one response: nextCursor is always null, and cursor, limit and the facet filters are not applied — they are still validated, so an out-of-range value answers 400 invalid_request with either kind.
Scope: voices:readlistVoices
Query
kindOptionalstringEnum"bank""own"Default:"bank"bank(default) lists the shared bank;ownlists your own clones (complete, unpaginated).languageOptionalstringEnum"ar""de""en""es""fr""hi""it""ja""ko""nl""pl""pt""ro""sv""tr""zh"accentOptionalstringEnum"american""andalusian""argentine""australian""austrian""bavarian""belgian""brazilian""british""canadian""castilian""catalan""chilean""colombian""cuban""european""filipino""french""galician""german""indian""irish""italian""jamaican""latin-american""mexican""neutral""new-zealand""nigerian""parisian""peruvian""puerto-rican""quebecois""scottish""singaporean""south-african""spanish""standard""swedish""swiss""transatlantic""tuscan""us-southern""venezuelan""welsh"ageOptionalstringEnum"young""adult""senior"genderOptionalstringEnum"female""male""neutral"useCaseOptionalstringEnum"advertisement""asmr""audiobook""characters""conversational""educational""entertainment""meditation""narration""news""social-media"cursorOptionalstringMinimum length:1Maximum length:256Opaque cursor of the next page, fromnextCursor. A cursor of another list answers 400invalid_cursor.limitOptionalintegerDefault:20Minimum:1Maximum:100Page size, 1–100 (default 20).
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
200One page of voices.
VoiceListResponseExample: bank — The shared bank (the default): one page, nextCursor to continue.
{
"data": [
{
"accent": "Castilian Spanish",
"accentCode": "castilian",
"ageCode": "adult",
"ageRange": "Adult",
"description": "Warm, close adult female voice in Castilian Spanish, for advertisements.",
"gender": "Female",
"genderCode": "female",
"id": "v4n8k2m6p1q9s3t7w5x1y2z4",
"kind": "bank",
"language": "Spanish",
"languageCode": "es",
"name": "Ana",
"sampleText": "Hi, I am Ana. This is how your ad sounds with my voice.",
"useCase": "Advertisement",
"useCaseCode": "advertisement"
},
{
"accent": "British English",
"accentCode": "british",
"ageCode": "young",
"ageRange": "Young",
"description": "Clear, energetic young male voice in British English, for narration.",
"gender": "Male",
"genderCode": "male",
"id": "l1a3m5v7o9i2c4e6b8d0f2h4",
"kind": "bank",
"language": "English",
"languageCode": "en",
"name": "Liam",
"sampleText": "Hi, I am Liam. This is how your ad sounds with my voice.",
"useCase": "Narration",
"useCaseCode": "narration"
}
],
"nextCursor": "eyJsIjoidm9pY2VzIiwiYyI6IjIwMjYtMDktMTJUMTg6MzA6MDAuMDAwWiIsImkiOiJsMWEzbTV2N285aTJjNGU2YjhkMGYyaDQifQ"
}Example: own — Your own clones (kind=own): complete in one response, nextCursor always null.
{
"data": [
{
"id": "o1w3n5v7o9i1c2e4b6d8f0h2",
"kind": "own",
"name": "My voice",
"status": "active"
}
],
"nextCursor": null
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
- invalid_cursor: The cursor is not one this list issued: malformed, or minted for another list. Restart from the first page.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "unavailable",
"message": "An error occurred on our side. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}GET/v1/voices/facets
List the served filter values
The codes, per dimension, for which the bank currently has at least one voice. Not paginated.
Scope: voices:readgetVoiceFacets
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
200The served filter values.
VoiceFacetsResponse{
"data": {
"accent": [
"castilian",
"mexican",
"british",
"american",
"brazilian",
"neutral"
],
"age": [
"young",
"adult",
"senior"
],
"gender": [
"female",
"male"
],
"language": [
"es",
"en",
"pt",
"de",
"fr",
"it"
],
"useCase": [
"advertisement",
"narration",
"social-media",
"educational"
]
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "unavailable",
"message": "An error occurred on our side. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}GET/v1/voices/{id}
Get a voice
One voice of the bank, or one of your own clones, with a short-lived link to its sample clip — kind says which of the two shapes you got. A clone with no clip yet gets one minted on this call; when that is not possible — a recycled clone, or a clip that could not be produced right now — sample is null: for an active clone, ask again later. A voice that is retired, belongs to someone else or does not exist answers 404.
Scope: voices:readgetVoice
Path
idRequiredCuid2Resource id (cuid2). An id that is not a cuid2 answers 400invalid_request; one that does not exist or is not yours answers 404not_found.
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
200The voice with its sample.
VoiceResponseExample: bank — A voice of the shared bank, with its short-lived sample clip.
{
"data": {
"accent": "Castilian Spanish",
"accentCode": "castilian",
"ageCode": "adult",
"ageRange": "Adult",
"description": "Warm, close adult female voice in Castilian Spanish, for advertisements.",
"gender": "Female",
"genderCode": "female",
"id": "v4n8k2m6p1q9s3t7w5x1y2z4",
"kind": "bank",
"language": "Spanish",
"languageCode": "es",
"name": "Ana",
"sample": {
"expiresAt": "2026-09-13T10:15:00.000Z",
"url": "https://media.makeityours.app/samples/v4n8k2m6p1q9s3t7w5x1y2z4.mp3?signature=example"
},
"sampleText": "Hi, I am Ana. This is how your ad sounds with my voice.",
"useCase": "Advertisement",
"useCaseCode": "advertisement"
}
}Example: own — One of your own clones, with the clip minted for it.
{
"data": {
"id": "o1w3n5v7o9i1c2e4b6d8f0h2",
"kind": "own",
"name": "My voice",
"sample": {
"expiresAt": "2026-09-13T10:15:00.000Z",
"url": "https://media.makeityours.app/samples/o1w3n5v7o9i1c2e4b6d8f0h2.mp3?signature=example"
},
"status": "active"
}
}Example: ownWithoutSample — One of your own clones with no clip available right now: sample is null.
{
"data": {
"id": "o1w3n5v7o9i1c2e4b6d8f0h2",
"kind": "own",
"name": "My voice",
"sample": null,
"status": "active"
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "unavailable",
"message": "An error occurred on our side. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}uploads
POST/v1/uploads
Start an upload
Registers a file you are about to upload and returns a presigned PUT URL plus the product limits. PUT the bytes there, then call the pre-flight. The server never fetches remote URLs. A repeated Idempotency-Key returns the same upload while it is still a draft, with a fresh putUrl — and discards any pre-flight already run on it, so run the pre-flight again after your PUT. Once that upload has been confirmed into a job under the same key, the key answers 409 idempotency_conflict. Nothing has been uploaded yet, so a 422 rejected here can only carry reason unsupported_container or too_large; the other reasons belong to the pre-flight.
Scope: jobs:writeIdempotency key: optionalcreateUpload
Headers
Idempotency-KeyOptionalstringPattern:^[\x21-\x7E]+$Minimum length:1Maximum length:255Client-chosen token (1–255 printable ASCII characters, no spaces; a UUID is ideal). The key is scoped to your account — every API key and OAuth client of yours shares one namespace. Replaying the same key with the same request (same operation, same body) returns the original result again for as long as the job it created exists: the key is bound to that job (for an upload, to its draft), so it is released only when the job is deleted or the abandoned draft is purged. The same key with a different request answers 409idempotency_conflict, and so does an upload key replayed after that upload was confirmed into a job (seePOST /v1/uploads). Optional on this operation.Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Request body
{
"contentType": "video/mp4",
"filename": "spring-spot.mp4",
"sizeBytes": 48213907
}Responses
201The upload is registered; PUT the file to putUrl.
{
"data": {
"constraints": {
"containers": [
"mp4",
"mov"
],
"maxDurationSeconds": 180,
"maxSizeBytes": 524288000
},
"expiresAt": "2026-09-13T11:00:00.000Z",
"putUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/source.mp4?signature=example",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
409Error codes:
- idempotency_conflict: The Idempotency-Key was already used with a different request, or with an upload that has since been confirmed into a job. Use a new key.
{
"error": {
"code": "idempotency_conflict",
"message": "This Idempotency-Key was already used with a different request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}422Error codes:
- rejected: The file was refused by the pre-flight: unsupported container, no video or audio track, too long or too large. error.reason says which — the closed ApiErrorReason enum — and message says the same thing in the negotiated language.
{
"error": {
"code": "rejected",
"message": "The file was refused: check the format, duration, size and audio track.",
"reason": "no_audio",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- upload_unavailable: The upload could not be registered (our side). Retry.
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "upload_unavailable",
"message": "We could not prepare the upload. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
- not_configured: The environment is missing a piece of configuration (our side). Retry later.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}POST/v1/uploads/{id}/preflight
Run the pre-flight of an upload
Analyses the uploaded file (container, codecs, duration) and persists the report. Call it after the PUT succeeded; before that it answers 409. A file outside the product limits or without an audio track answers 422 rejected with reason naming the rule (unsupported_container, no_video, no_audio, too_long or too_large) — the same rules the web applies.
Scope: jobs:writerunUploadPreflight
Path
idRequiredCuid2Resource id (cuid2). An id that is not a cuid2 answers 400invalid_request; one that does not exist or is not yours answers 404not_found.
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
200The file is analysed and ready to convert.
PreflightResponse{
"data": {
"preflight": {
"audioCodec": "aac",
"audioDurationS": 41.68,
"container": "mov,mp4,m4a,3gp,3g2,mj2",
"containerDurationS": 41.72,
"hasAudio": true,
"remuxSafe": true,
"vfrSuspected": false,
"videoCodec": "h264"
},
"ready": true,
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}409Error codes:
- upload_not_ready: The upload is not ready for this step: on the pre-flight, the PUT has not completed; on a job, the upload has not been pre-flighted yet.
{
"error": {
"code": "upload_not_ready",
"message": "The upload has not been analysed yet. Run the pre-flight first.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}422Error codes:
- rejected: The file was refused by the pre-flight: unsupported container, no video or audio track, too long or too large. error.reason says which — the closed ApiErrorReason enum — and message says the same thing in the negotiated language.
{
"error": {
"code": "rejected",
"message": "The file was refused: check the format, duration, size and audio track.",
"reason": "no_audio",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- preflight_unavailable: The file could not be analysed (our side). Retry.
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "preflight_unavailable",
"message": "We could not analyse the file. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
- not_configured: The environment is missing a piece of configuration (our side). Retry later.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}GET/v1/uploads/{id}/speakers
Get the speakers of an upload
The speaker detection of an upload: its status and, once ready, each speaker with the stretches in which it talks and a short presigned clip of its own voice. Read-only: it never starts a detection — 409 speakers_not_ready if none was requested, or if the last one stopped without an outcome (POST retries it once). Poll it while pending, for up to 15 minutes (then convert with one voice); every other status is final. Requires the several-voices feature (403 multi_voice_disabled otherwise). An upload whose pre-flight refused the file answers 409 not_confirmable.
Scope: jobs:readgetSpeakers
Path
idRequiredCuid2Resource id (cuid2). An id that is not a cuid2 answers 400invalid_request; one that does not exist or is not yours answers 404not_found.
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
200The detection as it stands now.
SpeakerDetectionResponseExample: pending — Still running: ask again.
{
"data": {
"speakers": [],
"status": "pending",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}Example: ready — Two speakers found: assign a voice to speaker 1 in POST /v1/jobs.
{
"data": {
"speakers": [
{
"index": 0,
"previewUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/speaker-0.m4a?signature=example",
"segments": [
{
"endMs": 12300,
"startMs": 0
},
{
"endMs": 41680,
"startMs": 28640
}
],
"totalMs": 25340
},
{
"index": 1,
"previewUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/speaker-1.m4a?signature=example",
"segments": [
{
"endMs": 28640,
"startMs": 12300
}
],
"totalMs": 16340
}
],
"status": "ready",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}Example: single — One speaker: convert with one voice.
{
"data": {
"speakers": [],
"status": "single",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
- multi_voice_disabled: The several-voices feature is not available to this account right now: it is switched off for the moment, the plan does not include it, or the account has no open billing period at this instant (a renewal in progress — retry in a while). Nothing was written. A job without speakers still converts with one voice per variant.
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scopemulti_voice_disabled
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}409Error codes:
- upload_not_ready: The upload is not ready for this step: on the pre-flight, the PUT has not completed; on a job, the upload has not been pre-flighted yet.
- not_confirmable: The upload cannot be turned into a job: its draft is gone or was already confirmed.
- speakers_not_ready: The upload has no finished speaker detection with several speakers: it was never requested, or it stopped without an outcome (request it with POST /v1/uploads/{id}/speakers — a stopped detection is retried once), it is still pending, or its outcome is single, too_many or failed. A job created from a previous job (sourceJobId) never has one: upload the video again. Otherwise create the job without speakers, or wait for ready.
{
"error": {
"code": "upload_not_ready",
"message": "The upload has not been analysed yet. Run the pre-flight first.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "unavailable",
"message": "An error occurred on our side. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}POST/v1/uploads/{id}/speakers
Detect the speakers of an upload
Asks for the speaker detection of a pre-flighted upload, so that a conversion can give each speaker its own voice (speakers of POST /v1/jobs). It runs in the background: the answer is 202 with the detection as it stands now, and GET on the same path tells you when it is no longer pending. Idempotent by upload, without Idempotency-Key: while a detection is pending, or once it has an outcome, calling it again changes nothing (an outcome is never recomputed — upload the file again for a new one); a detection that stopped without an outcome is retried once. It does not consume minutes. Up to 4 speakers are told apart; more is too_many. Requires the several-voices feature (403 multi_voice_disabled otherwise). An upload that has not been pre-flighted yet answers 409 upload_not_ready; one whose pre-flight refused the file answers 409 not_confirmable; one already turned into a job answers 404.
Scope: jobs:writestartSpeakerDetection
Path
idRequiredCuid2Resource id (cuid2). An id that is not a cuid2 answers 400invalid_request; one that does not exist or is not yours answers 404not_found.
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
202The detection is requested, or already has an outcome; its state as it stands now.
SpeakerDetectionResponseExample: pending — Requested: the detection runs in the background.
{
"data": {
"speakers": [],
"status": "pending",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}Example: ready — Already detected: calling again changes nothing.
{
"data": {
"speakers": [
{
"index": 0,
"previewUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/speaker-0.m4a?signature=example",
"segments": [
{
"endMs": 12300,
"startMs": 0
},
{
"endMs": 41680,
"startMs": 28640
}
],
"totalMs": 25340
},
{
"index": 1,
"previewUrl": "https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/speaker-1.m4a?signature=example",
"segments": [
{
"endMs": 28640,
"startMs": 12300
}
],
"totalMs": 16340
}
],
"status": "ready",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
- multi_voice_disabled: The several-voices feature is not available to this account right now: it is switched off for the moment, the plan does not include it, or the account has no open billing period at this instant (a renewal in progress — retry in a while). Nothing was written. A job without speakers still converts with one voice per variant.
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scopemulti_voice_disabled
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}409Error codes:
- upload_not_ready: The upload is not ready for this step: on the pre-flight, the PUT has not completed; on a job, the upload has not been pre-flighted yet.
- not_confirmable: The upload cannot be turned into a job: its draft is gone or was already confirmed.
{
"error": {
"code": "upload_not_ready",
"message": "The upload has not been analysed yet. Run the pre-flight first.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- enqueue_failed: The job could not be handed to the queue (our side). Retry.
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "enqueue_failed",
"message": "The job could not be queued. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}jobs
GET/v1/jobs
List jobs
Your jobs, newest first, paginated by cursor. Drafts (uploads not yet confirmed) are not listed.
Scope: jobs:readlistJobs
Query
cursorOptionalstringMinimum length:1Maximum length:256Opaque cursor of the next page, fromnextCursor. A cursor of another list answers 400invalid_cursor.limitOptionalintegerDefault:20Minimum:1Maximum:100Page size, 1–100 (default 20).
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
200One page of jobs.
JobListResponse{
"data": [
{
"createdAt": "2026-09-13T10:00:00.000Z",
"durationSeconds": 42,
"folderId": "f1o3l5d7e9r1a3b5c7d9e1f3",
"id": "k7f2q9x1m3p8w5z2c4v6b8n0",
"name": "Spring spot — Ana and Liam",
"narration": false,
"paused": false,
"status": "queued",
"tags": [
{
"id": "t2a4g6b8c0d2e4f6g8h0i2j4",
"name": "spring"
}
],
"unnamed": false,
"variantCount": 2
},
{
"createdAt": "2026-09-12T18:30:00.000Z",
"durationSeconds": 31,
"folderId": null,
"id": "j2b4d6f8h0k1m3p5r7t9v1x3",
"name": "Untitled conversion",
"narration": false,
"paused": false,
"status": "done",
"tags": [],
"unnamed": true,
"variantCount": 1
}
],
"nextCursor": "eyJsIjoiam9icyIsImMiOiIyMDI2LTA5LTEyVDE4OjMwOjAwLjAwMFoiLCJpIjoiajJiNGQ2ZjhoMGsxbTNwNXI3dDl2MXgzIn0"
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
- invalid_cursor: The cursor is not one this list issued: malformed, or minted for another list. Restart from the first page.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "unavailable",
"message": "An error occurred on our side. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}POST/v1/jobs
Create a conversion
Confirms a conversion from a pre-flighted upload or from a previous job of yours, with up to 3 voices. Consumes minutes from your plan exactly as the web does; the response carries the estimated plan/overage split. Idempotency-Key is required: the same key with the same request answers 202 again with the same job (its current state) and the same estimate; the same key with a different request answers 409. 404 not_found means the uploadId or sourceJobId is unknown or not yours; a voice that cannot be used is 422 invalid_voices. Exceeding your daily overage cap answers 402 and writes nothing. Optional speakers gives some speakers of the video another voice, per variant, for the same minutes: it needs uploadId and a ready detection of that upload (409 speakers_not_ready otherwise), a request that does not fit answers 400 speakers_mismatch, and an account without the feature answers 403 multi_voice_disabled — nothing is ever converted with one voice in its place.
Scope: jobs:writeIdempotency key: requiredcreateJob
Headers
Idempotency-KeyRequiredstringPattern:^[\x21-\x7E]+$Minimum length:1Maximum length:255Client-chosen token (1–255 printable ASCII characters, no spaces; a UUID is ideal). The key is scoped to your account — every API key and OAuth client of yours shares one namespace. Replaying the same key with the same request (same operation, same body) returns the original result again for as long as the job it created exists: the key is bound to that job (for an upload, to its draft), so it is released only when the job is deleted or the abandoned draft is purged. The same key with a different request answers 409idempotency_conflict, and so does an upload key replayed after that upload was confirmed into a job (seePOST /v1/uploads). Required on this operation.Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Request body
Example: oneVoice — One voice per variant (the usual request).
{
"name": "Spring spot — Ana and Liam",
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6",
"voiceIds": [
"v4n8k2m6p1q9s3t7w5x1y2z4",
"l1a3m5v7o9i2c4e6b8d0f2h4"
],
"voiceSettings": {
"v4n8k2m6p1q9s3t7w5x1y2z4": {
"similarity": 0.8,
"speakerBoost": true,
"stability": 0.45
}
}
}Example: withSpeakers — Speaker 1 of the upload in another voice, for the minutes of one voice.
{
"name": "Spring spot — two characters",
"speakers": [
{
"assignments": [
{
"speaker": 1,
"voiceId": "l1a3m5v7o9i2c4e6b8d0f2h4"
}
],
"variantVoiceId": "v4n8k2m6p1q9s3t7w5x1y2z4"
}
],
"uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6",
"voiceIds": [
"v4n8k2m6p1q9s3t7w5x1y2z4"
]
}Responses
202The conversion is queued.
JobCreatedResponseExample: oneVoice — Two variants, one voice each.
{
"data": {
"estimate": {
"currency": "usd",
"overageAmountCents": 18,
"overageSeconds": 18,
"planSeconds": 66
},
"job": {
"convertVariantsDone": 0,
"id": "k7f2q9x1m3p8w5z2c4v6b8n0",
"isNarration": false,
"isRemaster": false,
"paused": false,
"status": "queued",
"steps": [
{
"done": false,
"key": "prepare"
},
{
"done": false,
"key": "separate"
},
{
"done": false,
"key": "convert"
},
{
"done": false,
"key": "package"
},
{
"done": false,
"key": "finalize"
}
],
"variants": [
{
"driftMs": null,
"hasResult": false,
"speakers": [],
"variantId": "r3v5a7r9i1a3n5t7x9y1z3w5",
"voiceName": "Ana"
},
{
"driftMs": null,
"hasResult": false,
"speakers": [],
"variantId": "r4v6a8r0i2a4n6t8x0y2z4w6",
"voiceName": "Liam"
}
],
"variantsTotal": 2
}
}
}Example: withSpeakers — One variant whose speaker 1 takes another voice.
{
"data": {
"estimate": {
"currency": "usd",
"overageAmountCents": 0,
"overageSeconds": 0,
"planSeconds": 42
},
"job": {
"convertVariantsDone": 0,
"id": "k7f2q9x1m3p8w5z2c4v6b8n0",
"isNarration": false,
"isRemaster": false,
"paused": false,
"status": "queued",
"steps": [
{
"done": false,
"key": "prepare"
},
{
"done": false,
"key": "separate"
},
{
"done": false,
"key": "convert"
},
{
"done": false,
"key": "package"
},
{
"done": false,
"key": "finalize"
}
],
"variants": [
{
"driftMs": null,
"hasResult": false,
"speakers": [
{
"index": 1,
"voiceId": "l1a3m5v7o9i2c4e6b8d0f2h4"
}
],
"variantId": "r3v5a7r9i1a3n5t7x9y1z3w5",
"voiceName": "Ana"
}
],
"variantsTotal": 1
}
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
- idempotency_key_required: The operation requires an Idempotency-Key header and none was sent.
- speakers_mismatch: The speakers of the request are malformed or do not fit: not a list of { variantVoiceId, assignments }, a voice id that is not a cuid2, more entries or assignments than allowed, a variantVoiceId that is not in voiceIds or is listed twice, a speaker listed twice, speaker 0 (it always keeps the voice of its variant), or a speaker the detection of the upload did not find. Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}402Error codes:
- api_daily_overage_cap: This credential reached its daily overage cap (UTC day). Nothing was written; retry after midnight UTC or raise the cap in Settings.
{
"error": {
"code": "api_daily_overage_cap",
"message": "This credential reached its daily overage cap. Retry tomorrow or raise the cap in Settings.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
- multi_voice_disabled: The several-voices feature is not available to this account right now: it is switched off for the moment, the plan does not include it, or the account has no open billing period at this instant (a renewal in progress — retry in a while). Nothing was written. A job without speakers still converts with one voice per variant.
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scopemulti_voice_disabled
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}409Error codes:
- idempotency_conflict: The Idempotency-Key was already used with a different request, or with an upload that has since been confirmed into a job. Use a new key.
- speakers_not_ready: The upload has no finished speaker detection with several speakers: it was never requested, or it stopped without an outcome (request it with POST /v1/uploads/{id}/speakers — a stopped detection is retried once), it is still pending, or its outcome is single, too_many or failed. A job created from a previous job (sourceJobId) never has one: upload the video again. Otherwise create the job without speakers, or wait for ready.
- upload_not_ready: The upload is not ready for this step: on the pre-flight, the PUT has not completed; on a job, the upload has not been pre-flighted yet.
- not_confirmable: The upload cannot be turned into a job: its draft is gone or was already confirmed.
- invalid_state: The job is not in a state that admits the operation.
- account_closing: The account requested its deletion; no new jobs can be created while it closes.
- subscription_paused: The plan is paused (a billing incident); no new jobs until billing resumes.
{
"error": {
"code": "idempotency_conflict",
"message": "This Idempotency-Key was already used with a different request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}idempotency_conflictspeakers_not_readyupload_not_readynot_confirmableinvalid_stateaccount_closingsubscription_paused
422Error codes:
- invalid_voices: One or more of voiceIds, or of the voices in speakers, cannot be used for this job (unknown, not yours, retired or not usable right now).
- invalid_tune: A voiceSettings value is outside the documented range.
- overage_unavailable_currency: The job would need overage minutes and overage is billed in US dollars only, which is not the billing currency of this account. Stay within the plan allowance.
{
"error": {
"code": "invalid_voices",
"message": "One or more voices cannot be used for this job.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- enqueue_failed: The job could not be handed to the queue (our side). Retry.
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "enqueue_failed",
"message": "The job could not be queued. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
- voice_service_paused: Voice processing is temporarily paused. Retry later.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}Possible errors
rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestnot_foundidempotency_key_requiredidempotency_conflictapi_daily_overage_capspeakers_mismatchmulti_voice_disabledspeakers_not_readyupload_not_readynot_confirmableinvalid_stateinvalid_voicesinvalid_tuneoverage_unavailable_currencyvoice_service_pausedaccount_closingsubscription_pausedenqueue_failedunavailable
GET/v1/jobs/{id}
Get a job
The live state of one of your jobs, conversions and narrations alike. Poll it, or subscribe to webhooks for the terminal events. A draft (an upload not yet confirmed) is not a job: its id answers 404 here.
Scope: jobs:readgetJob
Path
idRequiredCuid2Resource id (cuid2). An id that is not a cuid2 answers 400invalid_request; one that does not exist or is not yours answers 404not_found.
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
200The job.
JobResponse{
"data": {
"convertVariantsDone": 1,
"id": "j2b4d6f8h0k1m3p5r7t9v1x3",
"isNarration": false,
"isRemaster": false,
"paused": false,
"status": "done",
"steps": [
{
"done": true,
"key": "prepare"
},
{
"done": true,
"key": "separate"
},
{
"done": true,
"key": "convert"
},
{
"done": true,
"key": "package"
},
{
"done": true,
"key": "finalize"
}
],
"variants": [
{
"driftMs": 120,
"hasResult": true,
"speakers": [],
"variantId": "r3v5a7r9i1a3n5t7x9y1z3w5",
"voiceName": "Ana"
}
],
"variantsTotal": 1
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "unavailable",
"message": "An error occurred on our side. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}POST/v1/jobs/{id}/cancel
Cancel a job
Requests the cancellation of a job that is confirmed, queued or processing. The cancellation is applied asynchronously by the worker and the reserved minutes are returned to your plan. A job already finished, failed or cancelled answers 409; a draft answers 404.
Scope: jobs:writecancelJob
Path
idRequiredCuid2Resource id (cuid2). An id that is not a cuid2 answers 400invalid_request; one that does not exist or is not yours answers 404not_found.
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
202The cancellation is queued; the job as it stands now.
JobResponse{
"data": {
"convertVariantsDone": 0,
"id": "k7f2q9x1m3p8w5z2c4v6b8n0",
"isNarration": false,
"isRemaster": false,
"paused": false,
"status": "processing",
"steps": [
{
"done": false,
"key": "prepare"
},
{
"done": false,
"key": "separate"
},
{
"done": false,
"key": "convert"
},
{
"done": false,
"key": "package"
},
{
"done": false,
"key": "finalize"
}
],
"variants": [
{
"driftMs": null,
"hasResult": false,
"speakers": [],
"variantId": "r3v5a7r9i1a3n5t7x9y1z3w5",
"voiceName": "Ana"
},
{
"driftMs": null,
"hasResult": false,
"speakers": [],
"variantId": "r4v6a8r0i2a4n6t8x0y2z4w6",
"voiceName": "Liam"
}
],
"variantsTotal": 2
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}409Error codes:
- job_not_cancellable: The job is already done, failed or cancelled; there is nothing left to cancel.
{
"error": {
"code": "job_not_cancellable",
"message": "This job has already finished and cannot be cancelled.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "unavailable",
"message": "An error occurred on our side. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}GET/v1/jobs/{id}/download
Download a result
Redirects (302) to a presigned URL, valid for 900 seconds, of the packaged result: the video, its audio extract, one stem track or the narration. The file is the same marked object the web serves. A job without that result answers 404. variant may be omitted only for a job with a single variant, which is then used; a job with several variants requires it (400 invalid_request otherwise).
Scope: jobs:readdownloadJob
Path
idRequiredCuid2Resource id (cuid2). An id that is not a cuid2 answers 400invalid_request; one that does not exist or is not yours answers 404not_found.
Query
formatOptionalstringEnum"video""audio""stems""narration"Default:"video"video(default), theaudioextract, onestemstrack, or thenarrationresult.stemOptionalRequired withstringEnum"vocals""background""voice"format=stems: which track.variantOptionalstringPattern:^[a-z][a-z0-9]{7,31}$Which variant to download when the job has several. Defaults to the first.
Headers
Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Responses
302Redirect to the presigned download URL (Location).
Headers:Location
400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- download_unavailable: The download link could not be prepared (our side). Retry.
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "download_unavailable",
"message": "We could not prepare the download. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
- not_configured: The environment is missing a piece of configuration (our side). Retry later.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}narrations
POST/v1/narrations
Create a narration
Narrates a script with one of your usable voices, as a job you then follow with GET /v1/jobs/{id} and download with format=narration. Same minutes, same estimate and same Idempotency-Key rules as a conversion.
Scope: narrations:writeIdempotency key: requiredcreateNarration
Headers
Idempotency-KeyRequiredstringPattern:^[\x21-\x7E]+$Minimum length:1Maximum length:255Client-chosen token (1–255 printable ASCII characters, no spaces; a UUID is ideal). The key is scoped to your account — every API key and OAuth client of yours shares one namespace. Replaying the same key with the same request (same operation, same body) returns the original result again for as long as the job it created exists: the key is bound to that job (for an upload, to its draft), so it is released only when the job is deleted or the abandoned draft is purged. The same key with a different request answers 409idempotency_conflict, and so does an upload key replayed after that upload was confirmed into a job (seePOST /v1/uploads). Required on this operation.Accept-LanguageOptionalstringLanguage oferror.messageand of the voice labels (gender,accent,ageRange,language,useCase,description), negotiated as on the website;enwhen absent or unsupported. Codes, field names anderror.codenever vary with it.
Request body
{
"name": "Spring narration",
"script": "This spring, refresh your home with twenty percent off the whole collection. Only until the end of the month.",
"voiceId": "v4n8k2m6p1q9s3t7w5x1y2z4"
}Responses
202The narration is queued.
JobCreatedResponse{
"data": {
"estimate": {
"currency": "usd",
"overageAmountCents": 0,
"overageSeconds": 0,
"planSeconds": 5
},
"job": {
"convertVariantsDone": 0,
"id": "n3a5r7r9a1t3i5o7n9j1o3b5",
"isNarration": true,
"isRemaster": false,
"paused": false,
"status": "queued",
"steps": [
{
"done": false,
"key": "prepare"
},
{
"done": false,
"key": "separate"
},
{
"done": false,
"key": "convert"
},
{
"done": false,
"key": "package"
},
{
"done": false,
"key": "finalize"
}
],
"variants": [
{
"driftMs": null,
"hasResult": false,
"speakers": [],
"variantId": "r5v7a9r1i3a5n7t9x1y3z5w7",
"voiceName": "Ana"
}
],
"variantsTotal": 1
}
}
}400Error codes:
- invalid_request: The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged.
- idempotency_key_required: The operation requires an Idempotency-Key header and none was sent.
{
"error": {
"code": "invalid_request",
"message": "The request is not valid.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}401Error codes:
- unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.
Headers:WWW-Authenticate
{
"error": {
"code": "unauthorized",
"message": "Provide a valid API key or access token.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}402Error codes:
- api_daily_overage_cap: This credential reached its daily overage cap (UTC day). Nothing was written; retry after midnight UTC or raise the cap in Settings.
{
"error": {
"code": "api_daily_overage_cap",
"message": "This credential reached its daily overage cap. Retry tomorrow or raise the cap in Settings.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}403Error codes:
- auth_email_not_verified: The account owning the credential has not confirmed its email address, so it cannot create jobs.
- api_access_required: The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does.
- insufficient_scope: The credential does not carry the scope this operation requires (x-scope).
{
"error": {
"code": "auth_email_not_verified",
"message": "Confirm your email before creating jobs.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}auth_email_not_verifiedapi_access_requiredinsufficient_scope
404Error codes:
- not_found: The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
{
"error": {
"code": "not_found",
"message": "We could not find that resource.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}409Error codes:
- idempotency_conflict: The Idempotency-Key was already used with a different request, or with an upload that has since been confirmed into a job. Use a new key.
- account_closing: The account requested its deletion; no new jobs can be created while it closes.
- subscription_paused: The plan is paused (a billing incident); no new jobs until billing resumes.
{
"error": {
"code": "idempotency_conflict",
"message": "This Idempotency-Key was already used with a different request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}422Error codes:
- invalid_script: The script is outside its length bounds after trimming, or carries no visible text.
- voice_not_usable: The voiceId cannot narrate (unknown, not yours, or not usable right now).
- overage_unavailable_currency: The job would need overage minutes and overage is billed in US dollars only, which is not the billing currency of this account. Stay within the plan allowance.
{
"error": {
"code": "invalid_script",
"message": "The script must be between 20 and 2500 characters.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}429Error codes:
- rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
Headers:Retry-After
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Wait and retry after the indicated seconds.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}500Error codes:
- enqueue_failed: The job could not be handed to the queue (our side). Retry.
- unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
{
"error": {
"code": "enqueue_failed",
"message": "The job could not be queued. Retry the request.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}503Error codes:
- api_disabled: The public API is switched off for now. Retry later; nothing was revoked.
- narration_disabled: Narration is switched off for now. Retry later.
- voice_service_paused: Voice processing is temporarily paused. Retry later.
{
"error": {
"code": "api_disabled",
"message": "The API is temporarily switched off.",
"requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
}
}Possible errors
rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestnot_foundidempotency_key_requiredidempotency_conflictapi_daily_overage_capnarration_disabledinvalid_scriptvoice_not_usableoverage_unavailable_currencyvoice_service_pausedaccount_closingsubscription_pausedenqueue_failedunavailable
Error catalogue#
Every code the API can answer with, and the status each one carries.
| Code | Status | Description |
|---|---|---|
| account_closing | 409 | The account requested its deletion; no new jobs can be created while it closes. |
| api_access_required | 403 | The plan of the account does not include API access (a Pro or Agency right). Credentials start working again as soon as the plan does. |
| api_daily_overage_cap | 402 | This credential reached its daily overage cap (UTC day). Nothing was written; retry after midnight UTC or raise the cap in Settings. |
| api_disabled | 503 | The public API is switched off for now. Retry later; nothing was revoked. |
| auth_email_not_verified | 403 | The account owning the credential has not confirmed its email address, so it cannot create jobs. |
| download_unavailable | 500 | The download link could not be prepared (our side). Retry. |
| enqueue_failed | 500 | The job could not be handed to the queue (our side). Retry. |
| idempotency_conflict | 409 | The Idempotency-Key was already used with a different request, or with an upload that has since been confirmed into a job. Use a new key. |
| idempotency_key_required | 400 | The operation requires an Idempotency-Key header and none was sent. |
| insufficient_scope | 403 | The credential does not carry the scope this operation requires (x-scope). |
| invalid_cursor | 400 | The cursor is not one this list issued: malformed, or minted for another list. Restart from the first page. |
| invalid_request | 400 | The body, query, path or headers do not match the schema of the operation (a path id that is not a cuid2 included). Fix the request; do not retry it unchanged. |
| invalid_script | 422 | The script is outside its length bounds after trimming, or carries no visible text. |
| invalid_state | 409 | The job is not in a state that admits the operation. |
| invalid_tune | 422 | A voiceSettings value is outside the documented range. |
| invalid_voices | 422 | One or more of voiceIds, or of the voices in speakers, cannot be used for this job (unknown, not yours, retired or not usable right now). |
| job_not_cancellable | 409 | The job is already done, failed or cancelled; there is nothing left to cancel. |
| mcp_disabled | 503 | The MCP server is switched off for now. Retry later; nothing was revoked. |
| multi_voice_disabled | 403 | The several-voices feature is not available to this account right now: it is switched off for the moment, the plan does not include it, or the account has no open billing period at this instant (a renewal in progress — retry in a while). Nothing was written. A job without speakers still converts with one voice per variant. |
| narration_disabled | 503 | Narration is switched off for now. Retry later. |
| not_configured | 503 | The environment is missing a piece of configuration (our side). Retry later. |
| not_confirmable | 409 | The upload cannot be turned into a job: its draft is gone or was already confirmed. |
| not_found | 404 | The resource does not exist or is not yours. The two cases are deliberately indistinguishable. |
| overage_unavailable_currency | 422 | The job would need overage minutes and overage is billed in US dollars only, which is not the billing currency of this account. Stay within the plan allowance. |
| preflight_unavailable | 500 | The file could not be analysed (our side). Retry. |
| rate_limited | 429 | Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry. |
| rejected | 422 | The file was refused by the pre-flight: unsupported container, no video or audio track, too long or too large. error.reason says which — the closed ApiErrorReason enum — and message says the same thing in the negotiated language. |
| speakers_mismatch | 400 | The speakers of the request are malformed or do not fit: not a list of { variantVoiceId, assignments }, a voice id that is not a cuid2, more entries or assignments than allowed, a variantVoiceId that is not in voiceIds or is listed twice, a speaker listed twice, speaker 0 (it always keeps the voice of its variant), or a speaker the detection of the upload did not find. Fix the request; do not retry it unchanged. |
| speakers_not_ready | 409 | The upload has no finished speaker detection with several speakers: it was never requested, or it stopped without an outcome (request it with POST /v1/uploads/{id}/speakers — a stopped detection is retried once), it is still pending, or its outcome is single, too_many or failed. A job created from a previous job (sourceJobId) never has one: upload the video again. Otherwise create the job without speakers, or wait for ready. |
| subscription_paused | 409 | The plan is paused (a billing incident); no new jobs until billing resumes. |
| unauthorized | 401 | No usable credential: the API key or access token is missing, malformed, revoked or expired. |
| unavailable | 500 | An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists. |
| upload_expired | 409 | The resumable upload can no longer be resumed: its parts expired or it was discarded. Start the upload again. Reserved for the resumable upload, which is not on /v1 yet: no /v1 operation answers it today. |
| upload_not_ready | 409 | The upload is not ready for this step: on the pre-flight, the PUT has not completed; on a job, the upload has not been pre-flighted yet. |
| upload_unavailable | 500 | The upload could not be registered (our side). Retry. |
| voice_not_usable | 422 | The voiceId cannot narrate (unknown, not yours, or not usable right now). |
| voice_service_paused | 503 | Voice processing is temporarily paused. Retry later. |
Schemas#
The shapes that travel in bodies and answers.
ApiErrorCode
message. Every code is explained in the x-error-catalog of this document and in each response that can answer it.stringEnum"invalid_request""invalid_cursor""idempotency_key_required""speakers_mismatch""unauthorized""api_daily_overage_cap""auth_email_not_verified""api_access_required""insufficient_scope""multi_voice_disabled""not_found""idempotency_conflict""upload_not_ready""job_not_cancellable""not_confirmable""invalid_state""account_closing""subscription_paused""speakers_not_ready""upload_expired""rejected""invalid_voices""invalid_tune""invalid_script""voice_not_usable""overage_unavailable_currency""rate_limited""unavailable""upload_unavailable""preflight_unavailable""download_unavailable""enqueue_failed""api_disabled""mcp_disabled""narration_disabled""not_configured""voice_service_paused"ApiErrorReason
rejected answer: the pre-flight rule that refused the file. Only a rejected (422) carries one.stringEnum"unsupported_container""no_video""no_audio""too_long""too_large"ApiScope
stringEnum"voices:read""jobs:read""jobs:write""narrations:write""webhooks:manage"CreateJobRequest
nameOptionalstringDisplay name (at most 120 characters). Empty or blank means no name.Maximum length:120sourceJobIdOptionalstringA previous job of yours whose video is reused. Exactly one ofuploadId/sourceJobId.Pattern:^[a-z][a-z0-9]{7,31}$speakersOptionalArray ofVariantSpeakersOptional: another voice for some speakers of the video, per variant. Any assignment — even one that repeats the variant voice — needsuploadId, the several-voices feature and areadyspeaker detection of that upload (POST /v1/uploads/{id}/speakers); a request that cannot have them is refused, never converted with one voice instead. The minutes are the same as with one voice. Absent, empty, or with only emptyassignments, the job is converted with one voice per variant, exactly as without this field.Maximum items:3uploadIdOptionalstringA pre-flighted upload (POST /v1/uploads/{id}/preflight). Exactly one ofuploadId/sourceJobId.Pattern:^[a-z][a-z0-9]{7,31}$voiceIdsRequiredArray ofCuid21 to 3 distinct voice ids; one variant is produced per voice.Minimum items:1Maximum items:3Items are distinctvoiceSettingsOptionalOptional tuning per voice id (only ids present invoiceIdsare used).Additional fieldssimilarityRequirednumberMinimum:0Maximum:1speakerBoostRequiredbooleanstabilityRequirednumberMinimum:0Maximum:1
uploadIdsourceJobIdCreateNarrationRequest
nameOptionalstringDisplay name (at most 120 characters). Empty or blank means no name.Maximum length:120scriptRequiredstringThe text to narrate: 20 to 2500 characters after trimming.Minimum length:20Maximum length:2500voiceIdRequiredstringThe voice that narrates. Must be usable for narration.Pattern:^[a-z][a-z0-9]{7,31}$
CreateUploadRequest
constraints in the response).contentTypeRequiredstringMIME type of the file (for examplevideo/mp4). The PUT must send it asContent-Type; the pre-flight decides on the real container.Minimum length:1Maximum length:200filenameRequiredstringOriginal file name, used only to pick the container extension.Minimum length:1Maximum length:500sizeBytesRequiredintegerSize of the file you are about to PUT, in bytes. Aboveconstraints.maxSizeBytes(see the response) the upload answers 422rejected.Minimum:0
CreateUploadResponse
POST /v1/uploads/{uploadId}/preflight.dataRequiredconstraintsRequiredUploadConstraintsexpiresAtRequiredstringWhenputUrlstops accepting the PUT — about an hour after this response. Start the upload right away: a single PUT must finish before then.Format:date-timeputUrlRequiredstringPresigned URL. PUT the raw bytes here with the sameContent-Type.Format:uriuploadIdRequiredCuid2
Cuid2
stringResource identifier (cuid2: 8 to 32 lowercase letters or digits, starting with a letter).Pattern: ^[a-z][a-z0-9]{7,31}$DetectedSpeaker
indexRequiredintegerThe speaker number, in order of first appearance. Speaker 0 keeps the voice of its variant; assign voices to speakers 1 and up.Minimum:0Maximum:3previewUrlRequiredA clip of a few seconds of this speaker's own voice, to tell the speakers apart. Presigned for 900 seconds: fetch it without your key, do not store it, and read the detection again for a fresh one. Null when this speaker has no clip.Any ofstringFormat:urinull
segmentsRequiredArray ofSpeakerSegmentThe stretches in which this speaker talks, in order.totalMsRequiredintegerHow long this speaker talks in the video, in milliseconds: the sum ofsegments.Minimum:0
DownloadUrlResult
expiresAtRequiredstringWhen the URL stops working: 900 seconds after it was issued.Format:date-timeurlRequiredstringPresigned GET URL of the result. The same object the 302 of the REST operation points at.Format:uri
ErrorEnvelope
errorRequiredcodeRequiredApiErrorCodemessageRequiredstringHuman-readable explanation in the negotiated language. Not stable; never branch on it.reasonOptionalApiErrorReasonrequestIdRequiredstringCorrelation id of this request. Quote it when contacting support.Minimum length:1Maximum length:128
JobCreatedResponse
dataRequiredestimateRequiredJobEstimatejobRequiredJobDetail
JobDetail
GET /v1/jobs/{id} serves it.convertVariantsDoneRequiredintegerVoices whose conversion has finished, out ofvariantsTotal.Minimum:0idRequiredCuid2isNarrationRequiredbooleanTrue for a narration: no video, and the conversion checklist does not apply.isRemasterRequiredbooleanTrue for a remaster (an audio re-edit of a previous conversion): theseparateandconvertsteps do not run.pausedRequiredbooleanProcessing is temporarily parked; it resumes on its own.statusRequiredJobStatusstepsRequiredArray ofJobStepThe five-step checklist of a conversion, in order. For a narration (isNarration) or a remaster (isRemaster) the steps that do not apply never turndone; rely onstatus.variantsRequiredArray ofJobVariantvariantsTotalRequiredintegerNumber of voices applied (theconvertstep counts up to it).Minimum:0
JobEstimate
currencyRequiredConstant"usd"ISO 4217 code, lowercase, ofoverageAmountCents. Overage is billed in US dollars only; an account billed in another currency cannot go beyond its allowance (422overage_unavailable_currency).overageAmountCentsRequiredintegerEstimated overage amount in whole cents ofcurrency.Minimum:0overageSecondsRequiredintegerSeconds beyond the allowance.Minimum:0planSecondsRequiredintegerSeconds charged to the plan allowance.Minimum:0
JobListResponse
dataRequiredArray ofLibraryItemnextCursorRequiredOpaque cursor of the next page, or null when this page was the last one.Any ofstringnull
JobResponse
dataRequiredJobDetail
JobStatus
confirmed → queued → processing → done | failed | cancelled (the last three are terminal). An upload that has not been confirmed is not a job and never appears here.stringEnum"confirmed""queued""processing""done""failed""cancelled"JobStep
done: false even when the job is done — read status for completion and steps only for the progress of a conversion.doneRequiredbooleankeyRequiredstringEnum"prepare""separate""convert""package""finalize"
JobVariant
driftMsRequiredMeasured timing drift in whole milliseconds, or null until measured.Any ofintegernull
hasResultRequiredbooleanWhether the packaged result exists and can be downloaded.speakersRequiredArray ofJobVariantSpeakerThe speakers of this variant converted with a voice other than the variant voice (speakersofPOST /v1/jobs), ordered byindex. Empty when the whole video takes the variant voice.variantIdRequiredCuid2voiceNameRequiredName of the voice applied, or null when that voice no longer exists.Any ofstringnull
JobVariantSpeaker
index, from the detection of its upload) converted with a voice (voiceId) other than the variant voice.indexRequiredintegerMinimum:1Maximum:3voiceIdRequiredCuid2
LibraryItem
GET /v1/jobs.createdAtRequiredstringFormat:date-timedurationSecondsRequiredAny ofintegernull
folderIdRequiredAny of- Cuid2
null
idRequiredCuid2nameRequiredstringnarrationRequiredbooleanpausedRequiredbooleanstatusRequiredJobStatustagsRequiredArray ofTagunnamedRequiredbooleanTrue whennameis the product fallback because the job has no name.variantCountRequiredintegerMinimum:0
McpErrorData
error.data of an MCP (JSON-RPC) error: the stable API code, the same correlation id as REST, and the same optional reason.codeRequiredApiErrorCodereasonOptionalApiErrorReasonrequestIdRequiredstringCorrelation id of this request. Quote it when contacting support.Minimum length:1Maximum length:128
OwnVoice
GET /v1/voices?kind=own.idRequiredCuid2kindRequiredConstant"own"nameRequiredstringThe name the holder gave this voice.statusRequiredactive— usable right now;recycled— kept, and rebuilt automatically the next time you use it.stringEnum"active""recycled"
OwnVoiceDetail
GET /v1/voices/{id}. sample is null when this voice has no clip yet.idRequiredCuid2kindRequiredConstant"own"nameRequiredstringThe name the holder gave this voice.sampleRequiredAny of- VoiceSample
null
statusRequiredactive— usable right now;recycled— kept, and rebuilt automatically the next time you use it.stringEnum"active""recycled"
PreflightReport
audioCodecRequiredAudio codec name as probed (for exampleaac), or null.Any ofstringnull
audioDurationSRequiredMeasured duration of the audio track, in seconds (not rounded). The minutes a job reserves round this up.Any ofnumbernull
containerRequiredContainer family as the probe reports it — for MP4/MOV files the ISOBMFF family stringmov,mp4,m4a,3gp,3g2,mj2. The accepted extensions areconstraints.containersof the upload.Any ofstringnull
containerDurationSRequiredDuration declared by the container, in seconds (not rounded).Any ofnumbernull
hasAudioRequiredbooleanWhether the file has an audio track.remuxSafeRequiredbooleanWhether the container can be rewritten without re-encoding the video.vfrSuspectedRequiredbooleanWhether the video looks variable-frame-rate.videoCodecRequiredVideo codec name as probed (for exampleh264), or null.Any ofstringnull
PreflightResponse
dataRequiredpreflightRequiredPreflightReportreadyRequiredConstanttrueAlways true when the pre-flight accepted the file; a refused file answers 422 instead.uploadIdRequiredCuid2
SpeakerAssignment
speakerRequiredintegerThe speaker (itsindexin the detection) that takesvoiceId. Speaker 0 cannot be assigned: it always keeps the voice of the variant.Minimum:1Maximum:3voiceIdRequiredstringThe voice for this speaker: a voice of the bank or one of your own, under the same rules asvoiceIds.Pattern:^[a-z][a-z0-9]{7,31}$
SpeakerDetection
ready, who talks when.speakersRequiredArray ofDetectedSpeakerThe speakers, ordered byindex— only whenstatusisready(2 to 4 of them); empty otherwise.Maximum items:4statusRequiredSpeakerDetectionStatusuploadIdRequiredCuid2
SpeakerDetectionResponse
dataRequiredSpeakerDetection
SpeakerDetectionStatus
pending: running, ask again (if it is still pending after 15 minutes, convert with one voice). ready: 2 to 4 speakers were found and speakers lists them — you can assign a voice to each one in POST /v1/jobs. single: one speaker (or no speech) — convert with one voice. too_many: more than 4 speakers — convert with one voice. failed: the detection could not finish — convert with one voice. Every status but pending is final.stringEnum"pending""ready""single""too_many""failed"SpeakerSegment
endMsRequiredintegerWhere it ends, in milliseconds from the start of the video.Minimum:0startMsRequiredintegerWhere the stretch starts, in milliseconds from the start of the video.Minimum:0
Tag
idRequiredCuid2nameRequiredstring
UploadConstraints
containersRequiredArray ofAccepted containers.stringEnum"mp4""mov"maxDurationSecondsRequiredConstant180Maximum video duration in seconds.maxSizeBytesRequiredConstant524288000Maximum object size in bytes.
VariantSpeakers
assignmentsRequiredArray ofSpeakerAssignmentOne entry per speaker that takes another voice. A speaker left out keeps the variant voice, and so does one assigned the variant voice.Maximum items:3variantVoiceIdRequiredstringThe variant this assignment belongs to, named by its voice: one ofvoiceIds. That voice is speaker 0 of the variant.Pattern:^[a-z][a-z0-9]{7,31}$
Voice
GET /v1/voices. Fetch GET /v1/voices/{id} for a playable sample.accentRequiredLabel ofaccentCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
accentCodeRequiredStable accent code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
ageCodeRequiredStable age code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
ageRangeRequiredLabel ofageCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
descriptionRequiredstringOne-paragraph description of the voice, in the negotiated language.genderRequiredLabel ofgenderCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
genderCodeRequiredStable gender code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
idRequiredCuid2kindRequiredConstant"bank"languageRequiredLabel oflanguageCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
languageCodeRequiredStable language code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
nameRequiredstringDisplay name of the voice.sampleTextRequiredstringWhat the sample clip says — in the language of the voice itself, not localised.useCaseRequiredLabel ofuseCaseCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
useCaseCodeRequiredStable use-case code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
VoiceDetail
GET /v1/voices/{id}.accentRequiredLabel ofaccentCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
accentCodeRequiredStable accent code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
ageCodeRequiredStable age code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
ageRangeRequiredLabel ofageCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
descriptionRequiredstringOne-paragraph description of the voice, in the negotiated language.genderRequiredLabel ofgenderCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
genderCodeRequiredStable gender code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
idRequiredCuid2kindRequiredConstant"bank"languageRequiredLabel oflanguageCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
languageCodeRequiredStable language code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
nameRequiredstringDisplay name of the voice.sampleRequiredVoiceSamplesampleTextRequiredstringWhat the sample clip says — in the language of the voice itself, not localised.useCaseRequiredLabel ofuseCaseCodein the negotiated language (Accept-Language,enby default), or null when unclassified. Copy, not a filter value.Any ofstringnull
useCaseCodeRequiredStable use-case code — one of the valuesGET /v1/voices/facetsserves, usable as aGET /v1/voicesfilter — or null when unclassified.Any ofstringnull
VoiceFacets
GET /v1/voices filters.accentRequiredArray ofstringEnum"american""andalusian""argentine""australian""austrian""bavarian""belgian""brazilian""british""canadian""castilian""catalan""chilean""colombian""cuban""european""filipino""french""galician""german""indian""irish""italian""jamaican""latin-american""mexican""neutral""new-zealand""nigerian""parisian""peruvian""puerto-rican""quebecois""scottish""singaporean""south-african""spanish""standard""swedish""swiss""transatlantic""tuscan""us-southern""venezuelan""welsh"ageRequiredArray ofstringEnum"young""adult""senior"genderRequiredArray ofstringEnum"female""male""neutral"languageRequiredArray ofstringEnum"ar""de""en""es""fr""hi""it""ja""ko""nl""pl""pt""ro""sv""tr""zh"useCaseRequiredArray ofstringEnum"advertisement""asmr""audiobook""characters""conversational""educational""entertainment""meditation""narration""news""social-media"
VoiceFacetsResponse
dataRequiredVoiceFacets
VoiceListResponse
VoiceResponse
kind says which of the two shapes it is.dataRequired
VoiceSample
expiresAtRequiredstringUntil when the URL is guaranteed to work.Format:date-timeurlRequiredstringURL of the sample clip (audio/mpeg). Do not store it: it is valid untilexpiresAt, then fetch the voice again for a fresh one.Format:uri
WebhookEvent
job.completed carries status: done and a download link; job.failed carries status: failed or cancelled (a cancelled job emits job.failed with status: cancelled) and no download. Verify the MIY-Signature header before trusting it.One ofcreatedAtRequiredstringFormat:date-timedataRequiredjobIdRequiredCuid2kindRequiredconversion(a remaster of a conversion included) ornarration.stringEnum"conversion""narration"linksRequireddownloadRequiredstringGET /v1/jobs/{id}/downloadof the result.Format:urijobRequiredstringGET /v1/jobs/{id}of this job.Format:uri
statusRequiredConstant"done"
idRequiredstringDelivery-independent event id. Deduplicate on it.Pattern:^[a-z][a-z0-9]{7,31}$typeRequiredConstant"job.completed"
createdAtRequiredstringFormat:date-timedataRequiredjobIdRequiredCuid2kindRequiredconversion(a remaster of a conversion included) ornarration.stringEnum"conversion""narration"linksRequireddownloadRequirednullAlways null: a failed or cancelled job has no result.jobRequiredstringGET /v1/jobs/{id}of this job.Format:uri
statusRequiredstringEnum"failed""cancelled"
idRequiredstringDelivery-independent event id. Deduplicate on it.Pattern:^[a-z][a-z0-9]{7,31}$typeRequiredConstant"job.failed"