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, as Authorization: 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.

Go to Settings → API

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 itvoices:readjobs:readjobs:writenarrations:write
Maximum length of the name, in characters64
Expiry, optional, in days1–365
Daily overage cap of the key, optional, in minutes1–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.

There is no sandbox and there are no test keys: every call runs against your real account and every job spends real minutes. Try the flow with a short file first.

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.

Terms of servicePrivacy policy

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 address300
Requests per minute with one credential120
Daily overage cap per credential, unless the key sets a lower one30 minutes
When the daily cap starts overat midnight, UTC
Header that says how many seconds to wait on a too-many-requests answerRetry-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.

Use the same key for the upload and for the job that confirms it: they are two steps of one intention, and sharing the key is what stops a retry of the first step from creating a second job. Reusing it after confirming IS a conflict, and you are told so with its own code.

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.

See the changelog

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.

  1. Step 1createUpload

    Register the upload and get the temporary address to put the file at, with its limits and its expiry.

    bash
    curl -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"
    }'
    Request
    json
    {
      "filename": "spring-spot.mp4",
      "sizeBytes": 48213907,
      "contentType": "video/mp4"
    }
    Response · 201
    json
    {
      "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"
          ]
        }
      }
    }
  2. 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.

    bash
    curl -X PUT 'https://media.makeityours.app/media/u1s3e5r7a9b1c3d5e7f9g1h3/u8p2l4o6a1d3i5n7g9s2t4r6/source.mp4?signature=example' \
      -H 'Content-Type: video/mp4' \
      --upload-file 'spring-spot.mp4'
  3. 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.

    bash
    curl -X POST 'https://www.makeityours.app/api/v1/uploads/u8p2l4o6a1d3i5n7g9s2t4r6/preflight' \
      -H 'x-api-key: miy_…'
    Response · 200
    json
    {
      "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
        }
      }
    }
  4. 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.

    bash
    curl -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
        }
      }
    }'
    Request
    json
    {
      "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6",
      "voiceIds": [
        "v4n8k2m6p1q9s3t7w5x1y2z4",
        "l1a3m5v7o9i2c4e6b8d0f2h4"
      ],
      "name": "Spring spot — Ana and Liam",
      "voiceSettings": {
        "v4n8k2m6p1q9s3t7w5x1y2z4": {
          "similarity": 0.8,
          "stability": 0.45,
          "speakerBoost": true
        }
      }
    }
    Response · 202
    json
    {
      "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"
        }
      }
    }
  5. 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.

    bash
    curl -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"
        }
      }
    }'
    http
    t=1789293852,v1=<hex HMAC-SHA256 of "t.body" with your endpoint secret>
    Request
    json
    {
      "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"
        }
      }
    }
  6. Step 6getJob

    Read the job whenever you want to know how it is going or which variants it produced.

    bash
    curl -X GET 'https://www.makeityours.app/api/v1/jobs/j2b4d6f8h0k1m3p5r7t9v1x3' \
      -H 'x-api-key: miy_…'
    Response · 200
    json
    {
      "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
      }
    }
  7. 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.

    bash
    curl -X GET 'https://www.makeityours.app/api/v1/jobs/j2b4d6f8h0k1m3p5r7t9v1x3/download' \
      -H 'x-api-key: miy_…' \
      -sS -o /dev/null -D -
    Then, without the key
    bash
    curl -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.

javascriptconvert.js
// 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);
});
pythonconvert.py
"""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.

  1. confirmed
  2. queued
  3. processing
  4. done(final)
  5. failed(final)
  6. 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.

  1. After the pre-flight of the upload, request the speaker detection. It runs in the background, and requesting it again changes nothing.
  2. 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.
  3. 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:

  1. pending
  2. ready(final)
  3. single(final)
  4. too_many(final)
  5. 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.

jsonA ready detection
{
  "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"
  }
}
jsonA conversion with another voice for one speaker
{
  "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 download does not return the file: it returns a redirect to a temporary address to fetch it from. That address belongs to another server and carries its own authorisation: fetch from it WITHOUT re-sending your key (many clients, curl included, forward custom headers when following a redirect) and do not store it, as it expires within minutes.

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.

Go to Settings → Webhooks

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>.
json
{
  "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>.
json
{
  "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

Every delivery carries a signature header with a timestamp and a digest computed with your endpoint secret over the timestamp, a dot and the EXACT body you received. Compute yours over the bytes as they arrive, without re-serialising the JSON, and compare in constant time. Reject the delivery when the timestamp falls outside the tolerance, even if the digest matches.

The header may carry more than one digest while you rotate the secret: accept the delivery if any of them matches.

javascript
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 };
python
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_SECONDS

Retries

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.

AttemptWait before the next one
11 minute
25 minutes
315 minutes
41 hour
54 hours
612 hours
724 hours
8None

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.

textServer address
https://www.makeityours.app/api/mcp
textProtected resource metadata
https://www.makeityours.app/.well-known/oauth-protected-resource
Permissions that can be requested

voices: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

  1. Open the assistant settings and find the section for connectors or external servers.
  2. Choose to add a remote server (not a local one and not a command).
  3. Paste the server address shown above.
  4. Accept when it sends you to the browser to sign in and authorise.
  5. 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:

bash
claude mcp add --transport http makeityours https://www.makeityours.app/api/mcp

Cursor

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:

json.cursor/mcp.json
{
  "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
Possible errors

rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestinvalid_cursorunavailable

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
Possible errors

rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestnot_foundunavailable

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
Possible errors

rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestinvalid_cursorunavailable

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
Possible errors

rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestnot_foundunavailable

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
Possible errors

rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestnot_foundnot_configureddownload_unavailableunavailable

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
Possible errors

rate_limitedapi_disabledunauthorizedauth_email_not_verifiedapi_access_requiredinsufficient_scopeinvalid_requestidempotency_conflictrejectednot_configuredupload_unavailableunavailable

Reference#

Generated from the contract: what is here is exactly what the API validates and answers.

Version: 1.1.0Base address: https://www.makeityours.app/api

voices:readjobs:readjobs:writenarrations:writewebhooks:manage

  • webhooks:manage — reserved: no operation or tool requires it yet.
The document this page is rendered from is also served as a file, without a credential, so you can generate a typed client or load it in your own viewer. Download the OpenAPI document https://www.makeityours.app/api/openapi.json

voices

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
  • kindOptional
    stringEnum"bank""own"Default: "bank"
    bank (default) lists the shared bank; own lists your own clones (complete, unpaginated).
  • languageOptional
    stringEnum"ar""de""en""es""fr""hi""it""ja""ko""nl""pl""pt""ro""sv""tr""zh"
  • accentOptional
    stringEnum"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"
  • ageOptional
    stringEnum"young""adult""senior"
  • genderOptional
    stringEnum"female""male""neutral"
  • useCaseOptional
    stringEnum"advertisement""asmr""audiobook""characters""conversational""educational""entertainment""meditation""narration""news""social-media"
  • cursorOptionalstringMinimum length: 1Maximum length: 256Opaque cursor of the next page, from nextCursor. A cursor of another list answers 400 invalid_cursor.
  • limitOptionalintegerDefault: 20Minimum: 1Maximum: 100Page size, 1–100 (default 20).
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

200One page of voices.

VoiceListResponse

Example: bank — The shared bank (the default): one page, nextCursor to continue.

json
{
  "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.

json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_requestinvalid_cursor

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

500Error codes: - unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.

ErrorEnvelope
json
{
  "error": {
    "code": "unavailable",
    "message": "An error occurred on our side. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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 of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

200The served filter values.

VoiceFacetsResponse
json
{
  "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

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

500Error codes: - unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.

ErrorEnvelope
json
{
  "error": {
    "code": "unavailable",
    "message": "An error occurred on our side. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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 400 invalid_request; one that does not exist or is not yours answers 404 not_found.
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

200The voice with its sample.

VoiceResponse

Example: bank — A voice of the shared bank, with its short-lived sample clip.

json
{
  "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.

json
{
  "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.

json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

500Error codes: - unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.

ErrorEnvelope
json
{
  "error": {
    "code": "unavailable",
    "message": "An error occurred on our side. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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 409 idempotency_conflict, and so does an upload key replayed after that upload was confirmed into a job (see POST /v1/uploads). Optional on this operation.
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Request body
json
{
  "contentType": "video/mp4",
  "filename": "spring-spot.mp4",
  "sizeBytes": 48213907
}
Responses

201The upload is registered; PUT the file to putUrl.

CreateUploadResponse
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "idempotency_conflict",
    "message": "This Idempotency-Key was already used with a different request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

idempotency_conflict

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.

ErrorEnvelope
json
{
  "error": {
    "code": "rejected",
    "message": "The file was refused: check the format, duration, size and audio track.",
    "reason": "no_audio",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rejected

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

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.

ErrorEnvelope
json
{
  "error": {
    "code": "upload_unavailable",
    "message": "We could not prepare the upload. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

upload_unavailableunavailable

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.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disablednot_configured

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 400 invalid_request; one that does not exist or is not yours answers 404 not_found.
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

200The file is analysed and ready to convert.

PreflightResponse
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

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.

ErrorEnvelope
json
{
  "error": {
    "code": "upload_not_ready",
    "message": "The upload has not been analysed yet. Run the pre-flight first.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

upload_not_ready

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.

ErrorEnvelope
json
{
  "error": {
    "code": "rejected",
    "message": "The file was refused: check the format, duration, size and audio track.",
    "reason": "no_audio",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rejected

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

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.

ErrorEnvelope
json
{
  "error": {
    "code": "preflight_unavailable",
    "message": "We could not analyse the file. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

preflight_unavailableunavailable

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.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disablednot_configured

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 400 invalid_request; one that does not exist or is not yours answers 404 not_found.
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

200The detection as it stands now.

SpeakerDetectionResponse

Example: pending — Still running: ask again.

json
{
  "data": {
    "speakers": [],
    "status": "pending",
    "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
  }
}

Example: ready — Two speakers found: assign a voice to speaker 1 in POST /v1/jobs.

json
{
  "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.

json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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.

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

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.

ErrorEnvelope
json
{
  "error": {
    "code": "upload_not_ready",
    "message": "The upload has not been analysed yet. Run the pre-flight first.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

upload_not_readynot_confirmablespeakers_not_ready

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

500Error codes: - unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.

ErrorEnvelope
json
{
  "error": {
    "code": "unavailable",
    "message": "An error occurred on our side. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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 400 invalid_request; one that does not exist or is not yours answers 404 not_found.
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

202The detection is requested, or already has an outcome; its state as it stands now.

SpeakerDetectionResponse

Example: pending — Requested: the detection runs in the background.

json
{
  "data": {
    "speakers": [],
    "status": "pending",
    "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6"
  }
}

Example: ready — Already detected: calling again changes nothing.

json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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.

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

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.

ErrorEnvelope
json
{
  "error": {
    "code": "upload_not_ready",
    "message": "The upload has not been analysed yet. Run the pre-flight first.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

upload_not_readynot_confirmable

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

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.

ErrorEnvelope
json
{
  "error": {
    "code": "enqueue_failed",
    "message": "The job could not be queued. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

enqueue_failedunavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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, from nextCursor. A cursor of another list answers 400 invalid_cursor.
  • limitOptionalintegerDefault: 20Minimum: 1Maximum: 100Page size, 1–100 (default 20).
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

200One page of jobs.

JobListResponse
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_requestinvalid_cursor

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

500Error codes: - unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.

ErrorEnvelope
json
{
  "error": {
    "code": "unavailable",
    "message": "An error occurred on our side. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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 409 idempotency_conflict, and so does an upload key replayed after that upload was confirmed into a job (see POST /v1/uploads). Required on this operation.
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Request body

Example: oneVoice — One voice per variant (the usual request).

json
{
  "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.

json
{
  "name": "Spring spot — two characters",
  "speakers": [
    {
      "assignments": [
        {
          "speaker": 1,
          "voiceId": "l1a3m5v7o9i2c4e6b8d0f2h4"
        }
      ],
      "variantVoiceId": "v4n8k2m6p1q9s3t7w5x1y2z4"
    }
  ],
  "uploadId": "u8p2l4o6a1d3i5n7g9s2t4r6",
  "voiceIds": [
    "v4n8k2m6p1q9s3t7w5x1y2z4"
  ]
}
Responses

202The conversion is queued.

JobCreatedResponse

Example: oneVoice — Two variants, one voice each.

json
{
  "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.

json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_requestidempotency_key_requiredspeakers_mismatch

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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.

ErrorEnvelope
json
{
  "error": {
    "code": "api_daily_overage_cap",
    "message": "This credential reached its daily overage cap. Retry tomorrow or raise the cap in Settings.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_daily_overage_cap

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.

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

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.

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_voices",
    "message": "One or more voices cannot be used for this job.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_voicesinvalid_tuneoverage_unavailable_currency

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

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.

ErrorEnvelope
json
{
  "error": {
    "code": "enqueue_failed",
    "message": "The job could not be queued. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

enqueue_failedunavailable

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.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabledvoice_service_paused

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 400 invalid_request; one that does not exist or is not yours answers 404 not_found.
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

200The job.

JobResponse
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

500Error codes: - unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.

ErrorEnvelope
json
{
  "error": {
    "code": "unavailable",
    "message": "An error occurred on our side. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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 400 invalid_request; one that does not exist or is not yours answers 404 not_found.
Headers
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Responses

202The cancellation is queued; the job as it stands now.

JobResponse
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

409Error codes: - job_not_cancellable: The job is already done, failed or cancelled; there is nothing left to cancel.

ErrorEnvelope
json
{
  "error": {
    "code": "job_not_cancellable",
    "message": "This job has already finished and cannot be cancelled.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

job_not_cancellable

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

500Error codes: - unavailable: An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.

ErrorEnvelope
json
{
  "error": {
    "code": "unavailable",
    "message": "An error occurred on our side. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unavailable

503Error codes: - api_disabled: The public API is switched off for now. Retry later; nothing was revoked.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disabled

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 400 invalid_request; one that does not exist or is not yours answers 404 not_found.
Query
  • formatOptional
    stringEnum"video""audio""stems""narration"Default: "video"
    video (default), the audio extract, one stems track, or the narration result.
  • stemOptional
    stringEnum"vocals""background""voice"
    Required with 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 of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never 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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_request

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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).

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

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.

ErrorEnvelope
json
{
  "error": {
    "code": "download_unavailable",
    "message": "We could not prepare the download. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

download_unavailableunavailable

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.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disablednot_configured

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 409 idempotency_conflict, and so does an upload key replayed after that upload was confirmed into a job (see POST /v1/uploads). Required on this operation.
  • Accept-LanguageOptionalstringLanguage of error.message and of the voice labels (gender, accent, ageRange, language, useCase, description), negotiated as on the website; en when absent or unsupported. Codes, field names and error.code never vary with it.
Request body
json
{
  "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
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_requestidempotency_key_required

401Error codes: - unauthorized: No usable credential: the API key or access token is missing, malformed, revoked or expired.

Headers:WWW-Authenticate

ErrorEnvelope
json
{
  "error": {
    "code": "unauthorized",
    "message": "Provide a valid API key or access token.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

unauthorized

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.

ErrorEnvelope
json
{
  "error": {
    "code": "api_daily_overage_cap",
    "message": "This credential reached its daily overage cap. Retry tomorrow or raise the cap in Settings.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_daily_overage_cap

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).

ErrorEnvelope
json
{
  "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.

ErrorEnvelope
json
{
  "error": {
    "code": "not_found",
    "message": "We could not find that resource.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

not_found

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.

ErrorEnvelope
json
{
  "error": {
    "code": "idempotency_conflict",
    "message": "This Idempotency-Key was already used with a different request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

idempotency_conflictaccount_closingsubscription_paused

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.

ErrorEnvelope
json
{
  "error": {
    "code": "invalid_script",
    "message": "The script must be between 20 and 2500 characters.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

invalid_scriptvoice_not_usableoverage_unavailable_currency

429Error codes: - rate_limited: Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.

Headers:Retry-After

ErrorEnvelope
json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Wait and retry after the indicated seconds.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

rate_limited

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.

ErrorEnvelope
json
{
  "error": {
    "code": "enqueue_failed",
    "message": "The job could not be queued. Retry the request.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

enqueue_failedunavailable

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.

ErrorEnvelope
json
{
  "error": {
    "code": "api_disabled",
    "message": "The API is temporarily switched off.",
    "requestId": "r2q4u6e8s1t3i5d7a9b1c3d5"
  }
}

api_disablednarration_disabledvoice_service_paused

Error catalogue#

Every code the API can answer with, and the status each one carries.

CodeStatusDescription
account_closing409The account requested its deletion; no new jobs can be created while it closes.
api_access_required403The 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_cap402This credential reached its daily overage cap (UTC day). Nothing was written; retry after midnight UTC or raise the cap in Settings.
api_disabled503The public API is switched off for now. Retry later; nothing was revoked.
auth_email_not_verified403The account owning the credential has not confirmed its email address, so it cannot create jobs.
download_unavailable500The download link could not be prepared (our side). Retry.
enqueue_failed500The job could not be handed to the queue (our side). Retry.
idempotency_conflict409The 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_required400The operation requires an Idempotency-Key header and none was sent.
insufficient_scope403The credential does not carry the scope this operation requires (x-scope).
invalid_cursor400The cursor is not one this list issued: malformed, or minted for another list. Restart from the first page.
invalid_request400The 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_script422The script is outside its length bounds after trimming, or carries no visible text.
invalid_state409The job is not in a state that admits the operation.
invalid_tune422A voiceSettings value is outside the documented range.
invalid_voices422One 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_cancellable409The job is already done, failed or cancelled; there is nothing left to cancel.
mcp_disabled503The MCP server is switched off for now. Retry later; nothing was revoked.
multi_voice_disabled403The 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_disabled503Narration is switched off for now. Retry later.
not_configured503The environment is missing a piece of configuration (our side). Retry later.
not_confirmable409The upload cannot be turned into a job: its draft is gone or was already confirmed.
not_found404The resource does not exist or is not yours. The two cases are deliberately indistinguishable.
overage_unavailable_currency422The 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_unavailable500The file could not be analysed (our side). Retry.
rate_limited429Too many requests from this IP or credential. Wait the seconds in Retry-After, then retry.
rejected422The 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_mismatch400The 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_ready409The 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_paused409The plan is paused (a billing incident); no new jobs until billing resumes.
unauthorized401No usable credential: the API key or access token is missing, malformed, revoked or expired.
unavailable500An unexpected failure on our side. Retry with backoff; quote requestId to support if it persists.
upload_expired409The 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_ready409The 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_unavailable500The upload could not be registered (our side). Retry.
voice_not_usable422The voiceId cannot narrate (unknown, not yours, or not usable right now).
voice_service_paused503Voice processing is temporarily paused. Retry later.

Schemas#

The shapes that travel in bodies and answers.

ApiErrorCode

Stable, documented error code. Branch on this, never on 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

Machine-readable detail of a 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

A scope an API key or OAuth authorisation can carry.stringEnum"voices:read""jobs:read""jobs:write""narrations:write""webhooks:manage"

CreateJobRequest

Create a conversion: the video source, the voices to apply, optional tuning and, optionally, a voice per speaker.
  • nameOptionalstringDisplay name (at most 120 characters). Empty or blank means no name.Maximum length: 120
  • sourceJobIdOptionalstringA previous job of yours whose video is reused. Exactly one of uploadId / sourceJobId.Pattern: ^[a-z][a-z0-9]{7,31}$
  • speakersOptional
    Optional: another voice for some speakers of the video, per variant. Any assignment — even one that repeats the variant voice — needs uploadId, the several-voices feature and a ready speaker 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 empty assignments, the job is converted with one voice per variant, exactly as without this field.Maximum items: 3
  • uploadIdOptionalstringA pre-flighted upload (POST /v1/uploads/{id}/preflight). Exactly one of uploadId / sourceJobId.Pattern: ^[a-z][a-z0-9]{7,31}$
  • voiceIdsRequired
    Array ofCuid2
    1 to 3 distinct voice ids; one variant is produced per voice.Minimum items: 1Maximum items: 3Items are distinct
  • voiceSettingsOptional
    Optional tuning per voice id (only ids present in voiceIds are used).
    Additional fields
    • similarityRequirednumberMinimum: 0Maximum: 1
    • speakerBoostRequiredboolean
    • stabilityRequirednumberMinimum: 0Maximum: 1
Exactly one of these groups of fieldsuploadIdsourceJobId

CreateNarrationRequest

Create a narration: a voice and a script.
  • nameOptionalstringDisplay name (at most 120 characters). Empty or blank means no name.Maximum length: 120
  • scriptRequiredstringThe text to narrate: 20 to 2500 characters after trimming.Minimum length: 20Maximum length: 2500
  • voiceIdRequiredstringThe voice that narrates. Must be usable for narration.Pattern: ^[a-z][a-z0-9]{7,31}$

CreateUploadRequest

What you are about to upload. The product limits apply (see constraints in the response).
  • contentTypeRequiredstringMIME type of the file (for example video/mp4). The PUT must send it as Content-Type; the pre-flight decides on the real container.Minimum length: 1Maximum length: 200
  • filenameRequiredstringOriginal file name, used only to pick the container extension.Minimum length: 1Maximum length: 500
  • sizeBytesRequiredintegerSize of the file you are about to PUT, in bytes. Above constraints.maxSizeBytes (see the response) the upload answers 422 rejected.Minimum: 0

CreateUploadResponse

Where to PUT the file. Then call POST /v1/uploads/{uploadId}/preflight.
  • dataRequired
    • constraintsRequiredUploadConstraints
    • expiresAtRequiredstringWhen putUrl stops accepting the PUT — about an hour after this response. Start the upload right away: a single PUT must finish before then.Format: date-time
    • putUrlRequiredstringPresigned URL. PUT the raw bytes here with the same Content-Type.Format: uri
    • uploadIdRequiredCuid2

Cuid2

stringResource identifier (cuid2: 8 to 32 lowercase letters or digits, starting with a letter).Pattern: ^[a-z][a-z0-9]{7,31}$

DetectedSpeaker

One speaker found in the upload.
  • 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: 3
  • previewUrlRequired
    A 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 of
    • stringFormat: uri
    • null
  • segmentsRequired
    The stretches in which this speaker talks, in order.
  • totalMsRequiredintegerHow long this speaker talks in the video, in milliseconds: the sum of segments.Minimum: 0

DownloadUrlResult

A short-lived download URL and its expiry — the MCP answer to a download (no redirect).
  • expiresAtRequiredstringWhen the URL stops working: 900 seconds after it was issued.Format: date-time
  • urlRequiredstringPresigned GET URL of the result. The same object the 302 of the REST operation points at.Format: uri

ErrorEnvelope

The single error shape of the API. Every non-2xx JSON response has it.
  • errorRequired
    • codeRequiredApiErrorCode
    • messageRequiredstringHuman-readable explanation in the negotiated language. Not stable; never branch on it.
    • reasonOptionalApiErrorReason
    • requestIdRequiredstringCorrelation id of this request. Quote it when contacting support.Minimum length: 1Maximum length: 128

JobCreatedResponse

The job was accepted and queued, with its estimate.

JobDetail

The live state of one job, as GET /v1/jobs/{id} serves it.
  • convertVariantsDoneRequiredintegerVoices whose conversion has finished, out of variantsTotal.Minimum: 0
  • idRequiredCuid2
  • isNarrationRequiredbooleanTrue for a narration: no video, and the conversion checklist does not apply.
  • isRemasterRequiredbooleanTrue for a remaster (an audio re-edit of a previous conversion): the separate and convert steps do not run.
  • pausedRequiredbooleanProcessing is temporarily parked; it resumes on its own.
  • statusRequiredJobStatus
  • stepsRequired
    Array ofJobStep
    The five-step checklist of a conversion, in order. For a narration (isNarration) or a remaster (isRemaster) the steps that do not apply never turn done; rely on status.
  • variantsRequired
    Array ofJobVariant
  • variantsTotalRequiredintegerNumber of voices applied (the convert step counts up to it).Minimum: 0

JobEstimate

The estimated plan/overage split of a job. Settled against the real duration when the job finishes.
  • currencyRequiredConstant"usd"ISO 4217 code, lowercase, of overageAmountCents. Overage is billed in US dollars only; an account billed in another currency cannot go beyond its allowance (422 overage_unavailable_currency).
  • overageAmountCentsRequiredintegerEstimated overage amount in whole cents of currency.Minimum: 0
  • overageSecondsRequiredintegerSeconds beyond the allowance.Minimum: 0
  • planSecondsRequiredintegerSeconds charged to the plan allowance.Minimum: 0

JobListResponse

One page of jobs, newest first.
  • dataRequired
    Array ofLibraryItem
  • nextCursorRequired
    Opaque cursor of the next page, or null when this page was the last one.Any of
    • string
    • null

JobResponse

One job.

JobStatus

Lifecycle state of a job: 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

One step of the conversion checklist. A narration or a remaster does not run every step: the ones that do not apply stay done: false even when the job is done — read status for completion and steps only for the progress of a conversion.
  • doneRequiredboolean
  • keyRequired
    stringEnum"prepare""separate""convert""package""finalize"

JobVariant

One voice applied to the job.
  • driftMsRequired
    Measured timing drift in whole milliseconds, or null until measured.Any of
    • integer
    • null
  • hasResultRequiredbooleanWhether the packaged result exists and can be downloaded.
  • speakersRequired
    The speakers of this variant converted with a voice other than the variant voice (speakers of POST /v1/jobs), ordered by index. Empty when the whole video takes the variant voice.
  • variantIdRequiredCuid2
  • voiceNameRequired
    Name of the voice applied, or null when that voice no longer exists.Any of
    • string
    • null

JobVariantSpeaker

A speaker of the variant (index, from the detection of its upload) converted with a voice (voiceId) other than the variant voice.
  • indexRequiredintegerMinimum: 1Maximum: 3
  • voiceIdRequiredCuid2

LibraryItem

One job as listed by GET /v1/jobs.
  • createdAtRequiredstringFormat: date-time
  • durationSecondsRequired
    Any of
    • integer
    • null
  • folderIdRequired
    Any of
  • idRequiredCuid2
  • nameRequiredstring
  • narrationRequiredboolean
  • pausedRequiredboolean
  • statusRequiredJobStatus
  • tagsRequired
    Array ofTag
  • unnamedRequiredbooleanTrue when name is the product fallback because the job has no name.
  • variantCountRequiredintegerMinimum: 0

McpErrorData

The error.data of an MCP (JSON-RPC) error: the stable API code, the same correlation id as REST, and the same optional reason.
  • codeRequiredApiErrorCode
  • reasonOptionalApiErrorReason
  • requestIdRequiredstringCorrelation id of this request. Quote it when contacting support.Minimum length: 1Maximum length: 128

OwnVoice

One of your own cloned voices, as listed by GET /v1/voices?kind=own.
  • idRequiredCuid2
  • kindRequiredConstant"own"
  • nameRequiredstringThe name the holder gave this voice.
  • statusRequired
    active — usable right now; recycled — kept, and rebuilt automatically the next time you use it.stringEnum"active""recycled"

OwnVoiceDetail

One of your own cloned voices with its sample clip, as returned by GET /v1/voices/{id}. sample is null when this voice has no clip yet.
  • idRequiredCuid2
  • kindRequiredConstant"own"
  • nameRequiredstringThe name the holder gave this voice.
  • sampleRequired
    Any of
  • statusRequired
    active — usable right now; recycled — kept, and rebuilt automatically the next time you use it.stringEnum"active""recycled"

PreflightReport

The technical analysis of the uploaded file: container, codecs, durations (seconds), variable-frame-rate suspicion and whether a remux is safe.
  • audioCodecRequired
    Audio codec name as probed (for example aac), or null.Any of
    • string
    • null
  • audioDurationSRequired
    Measured duration of the audio track, in seconds (not rounded). The minutes a job reserves round this up.Any of
    • number
    • null
  • containerRequired
    Container family as the probe reports it — for MP4/MOV files the ISOBMFF family string mov,mp4,m4a,3gp,3g2,mj2. The accepted extensions are constraints.containers of the upload.Any of
    • string
    • null
  • containerDurationSRequired
    Duration declared by the container, in seconds (not rounded).Any of
    • number
    • null
  • 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.
  • videoCodecRequired
    Video codec name as probed (for example h264), or null.Any of
    • string
    • null

PreflightResponse

The upload is analysed and can be turned into a job.
  • dataRequired
    • preflightRequiredPreflightReport
    • readyRequiredConstanttrueAlways true when the pre-flight accepted the file; a refused file answers 422 instead.
    • uploadIdRequiredCuid2

SpeakerAssignment

One speaker of a variant and the voice it takes.
  • speakerRequiredintegerThe speaker (its index in the detection) that takes voiceId. Speaker 0 cannot be assigned: it always keeps the voice of the variant.Minimum: 1Maximum: 3
  • voiceIdRequiredstringThe voice for this speaker: a voice of the bank or one of your own, under the same rules as voiceIds.Pattern: ^[a-z][a-z0-9]{7,31}$

SpeakerDetection

The speaker detection of an upload: its status and, once ready, who talks when.

SpeakerDetectionResponse

The speaker detection of an upload, as it stands now.

SpeakerDetectionStatus

State of the speaker detection of an upload. 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

One stretch of the video in which this speaker talks.
  • endMsRequiredintegerWhere it ends, in milliseconds from the start of the video.Minimum: 0
  • startMsRequiredintegerWhere the stretch starts, in milliseconds from the start of the video.Minimum: 0

Tag

A tag of the library.
  • idRequiredCuid2
  • nameRequiredstring

UploadConstraints

The fixed product limits every upload must respect.
  • containersRequired
    Array of
    stringEnum"mp4""mov"
    Accepted containers.
  • maxDurationSecondsRequiredConstant180Maximum video duration in seconds.
  • maxSizeBytesRequiredConstant524288000Maximum object size in bytes.

VariantSpeakers

The voices of the speakers of one variant.
  • assignmentsRequired
    One 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: 3
  • variantVoiceIdRequiredstringThe variant this assignment belongs to, named by its voice: one of voiceIds. That voice is speaker 0 of the variant.Pattern: ^[a-z][a-z0-9]{7,31}$

Voice

A voice of the shared bank, as listed by GET /v1/voices. Fetch GET /v1/voices/{id} for a playable sample.
  • accentRequired
    Label of accentCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • accentCodeRequired
    Stable accent code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • ageCodeRequired
    Stable age code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • ageRangeRequired
    Label of ageCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • descriptionRequiredstringOne-paragraph description of the voice, in the negotiated language.
  • genderRequired
    Label of genderCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • genderCodeRequired
    Stable gender code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • idRequiredCuid2
  • kindRequiredConstant"bank"
  • languageRequired
    Label of languageCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • languageCodeRequired
    Stable language code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • nameRequiredstringDisplay name of the voice.
  • sampleTextRequiredstringWhat the sample clip says — in the language of the voice itself, not localised.
  • useCaseRequired
    Label of useCaseCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • useCaseCodeRequired
    Stable use-case code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null

VoiceDetail

A voice of the bank with its sample clip, as returned by GET /v1/voices/{id}.
  • accentRequired
    Label of accentCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • accentCodeRequired
    Stable accent code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • ageCodeRequired
    Stable age code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • ageRangeRequired
    Label of ageCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • descriptionRequiredstringOne-paragraph description of the voice, in the negotiated language.
  • genderRequired
    Label of genderCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • genderCodeRequired
    Stable gender code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • idRequiredCuid2
  • kindRequiredConstant"bank"
  • languageRequired
    Label of languageCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • languageCodeRequired
    Stable language code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null
  • nameRequiredstringDisplay name of the voice.
  • sampleRequiredVoiceSample
  • sampleTextRequiredstringWhat the sample clip says — in the language of the voice itself, not localised.
  • useCaseRequired
    Label of useCaseCode in the negotiated language (Accept-Language, en by default), or null when unclassified. Copy, not a filter value.Any of
    • string
    • null
  • useCaseCodeRequired
    Stable use-case code — one of the values GET /v1/voices/facets serves, usable as a GET /v1/voices filter — or null when unclassified.Any of
    • string
    • null

VoiceFacets

The filter values the bank currently serves, per dimension. Use them as GET /v1/voices filters.
  • accentRequired
    Array of
    stringEnum"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"
  • ageRequired
    Array of
    stringEnum"young""adult""senior"
  • genderRequired
    Array of
    stringEnum"female""male""neutral"
  • languageRequired
    Array of
    stringEnum"ar""de""en""es""fr""hi""it""ja""ko""nl""pl""pt""ro""sv""tr""zh"
  • useCaseRequired
    Array of
    stringEnum"advertisement""asmr""audiobook""characters""conversational""educational""entertainment""meditation""narration""news""social-media"

VoiceFacetsResponse

The served filter values.

VoiceListResponse

One page of voices. Every item carries kind: bank for a voice of the shared bank, own for one of your clones.
  • dataRequired
    Array of
  • nextCursorRequired
    Opaque cursor of the next page, or null when this page was the last one.Any of
    • string
    • null

VoiceResponse

One voice with its sample. kind says which of the two shapes it is.

VoiceSample

A short-lived link to the voice sample clip.
  • expiresAtRequiredstringUntil when the URL is guaranteed to work.Format: date-time
  • urlRequiredstringURL of the sample clip (audio/mpeg). Do not store it: it is valid until expiresAt, then fetch the voice again for a fresh one.Format: uri

WebhookEvent

The body of every webhook delivery. 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 of
    • createdAtRequiredstringFormat: date-time
    • dataRequired
      • jobIdRequiredCuid2
      • kindRequired
        conversion (a remaster of a conversion included) or narration.stringEnum"conversion""narration"
      • linksRequired
        • downloadRequiredstringGET /v1/jobs/{id}/download of the result.Format: uri
        • jobRequiredstringGET /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-time
    • dataRequired
      • jobIdRequiredCuid2
      • kindRequired
        conversion (a remaster of a conversion included) or narration.stringEnum"conversion""narration"
      • linksRequired
        • downloadRequirednullAlways null: a failed or cancelled job has no result.
        • jobRequiredstringGET /v1/jobs/{id} of this job.Format: uri
      • statusRequired
        stringEnum"failed""cancelled"
    • idRequiredstringDelivery-independent event id. Deduplicate on it.Pattern: ^[a-z][a-z0-9]{7,31}$
    • typeRequiredConstant"job.failed"