API documentation

Everything on the website is available over HTTP. All requests and responses are JSON, except file uploads.

Getting started

  1. Create an account and open API Keys & Credits to make a key.
  2. Send the key in an X-Api-Key header (or Authorization: Bearer tn_live_...) on every request.
  3. Requests that make audio return a job id straight away. Poll the job until it is done, then use the MP3 links in its result.
curl https://YOUR_HOST/api/v1/voices?scope=system -H "X-Api-Key: tn_live_xxxxxxxx"

Jobs

Anything that generates audio is queued. The response has a jobId; fetch it until status is done or failed. Audio files are hosted on a CDN, so the links in a result can be cached and shared.

GET/api/v1/jobs/{id}
{
  "id": "b424363f-...", "type": "tts", "status": "done", "progress": 100, "message": "Done", "tokens": 7,
  "result": {
    "finalUrl": "https://.../final.mp3", "finalSeconds": 7.59,
    "lines": [ { "lineId": "c7ceb222-...", "url": "https://.../line-1.mp3", "seconds": 4.99 } ]
  }
}

Status is queued, running, done or failed. A failed job has an error message and its credits are refunded. Poll every 1 to 3 seconds. GET /api/v1/jobs lists your recent jobs.

Credits and errors

Audio is billed by the second (see costs in GET /api/v1/account). An estimate is taken when a job is queued, settled to the real length when it finishes, and returned in full if it fails. Errors look like this:

{ "error": "Line 1: choose a voice.", "code": "voice_required" }
400Something in the request needs fixing; error says what.
401Missing or invalid API key.
402Not enough credits (cost and balance are included).
404Not found, or not yours.
429Too many requests. Slow down.

Plans and credits

Credits pay for everything, and audio is billed by the second: 1 credit buys 0.06 seconds, so 10,000 credits are 10 minutes of audio, whether it is speech, a sound effect or music (see costs in GET /api/v1/account). When you queue speech you are charged an estimate from the text; when the audio is finished you are charged for its real length and the difference is refunded or taken. A free account gets 10,000 credits when it is created and again each month, on the anniversary of signup. Paid plans add their monthly credits on the same day each month, and unused credits roll over: your balance can reach three times the monthly amount when credits arrive.

GET/api/v1/billing/plans

Public. Returns every plan with its price, monthly credits, audioMinutes, rolloverCap and the price of extra credits (extraPer10k). See also the pricing page.

GET/api/v1/billing

Your plan, balance, when the next credits arrive and whether the plan is cancelled or has a payment problem. Subscribing, changing plan, cancelling and buying credits are only available while signed in to the website, not with an API key.

Text to speech

POST/api/v1/tts

One or more lines, each with its own voice, emotion, filter and audio settings. You get an MP3 per line and, by default, a joined track.

{
  "lines": [
    { "text": "Calling all units.", "voiceId": "VOICE_ID", "filterId": "FILTER_ID", "output": { "pan": -0.5 } },
    { "text": "Copy that.", "voiceId": "OTHER_VOICE_ID", "emotion": "angry", "pauseAfterMs": 600 }
  ],
  "output": { "bitrate": 192, "roomTone": true },
  "gapMs": 350,
  "join": true
}
Line fieldMeaning
textUp to 2000 characters. Tags such as [laugh] or [whispering] work inside the text for cloned and designed voices.
voiceIdA voice from the library.
emotionangry, whisper, joyful, sad, sarcastic, scared, surprised, dramatic, neutral. Uses the voice's emotion variant if it has one.
filterId or filterA saved filter, or an inline recipe. If neither is given, the voice's default filter applies.
intensity0 to 1 strength for the filter's intensity formulas.
outputAudio settings for this line (see below).
pauseAfterMsSilence after this line in the joined track.

POST /api/v1/tts/speak is a shorthand for one line (text, voiceId, emotion, filterId, output). POST /api/v1/tts/estimate returns the credit cost without running anything.

Live streaming

POST/api/v1/tts/stream

Speaks one piece of text and sends the audio while it is still being made, so a listener hears the first words in about a second instead of waiting for a finished file. The response is the audio itself (chunked), not JSON: raw 16-bit little-endian mono PCM at 24 kHz (audio/pcm, with X-Sample-Rate, X-Sample-Format and X-Channels headers), or with "format": "wav" the same audio behind a streaming WAV header for players that cannot take raw PCM.

FieldMeaning
textUp to 2000 characters (about two and a half minutes of speech). Use a normal speak request for more.
voiceIdA voice you can use.
emotionSame as for a normal line.
formatpcm (default) or wav.

Streams have no filters, output settings, joined files or saved result: for those use POST /api/v1/tts/speak, which queues a job. A stream is charged up front like a one-line speak, refunded in full if nothing could be produced, and kept if audio had already started. If the voice worker fails part way, the connection is cut, so a broken stream shows as a connection error rather than as audio that ends early.

Errors are JSON before the audio starts: 400 for a bad request or a voice the speech model rejects (voice_rejected), 402 not enough credits, 429 stream_limit (you have too many open; the default is 2), 503 streaming_busy, streaming_unavailable or streaming_disabled (with Retry-After when it is worth trying again), and 502 when the worker could not produce the audio. Designed and cloned voices take much more of a worker's capacity than the built-in ones, so streaming_busy is more likely for them. GET /api/v1/tts/stream/status shows whether streaming is on and how many workers are connected.

Start playing after a short head start (half a second is plenty) and keep appending what arrives:

const res = await fetch(base + "/api/v1/tts/stream", {
  method: "POST", headers, body: JSON.stringify({ text: "Hello there.", voiceId: VOICE_ID })
});
if (!res.ok) throw await res.json();            // for example 503 streaming_busy: try again in a moment

const ctx = new AudioContext({ sampleRate: 24000 });
const reader = res.body.getReader();
let at = ctx.currentTime + 0.5, carry = new Uint8Array(0);
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  const bytes = new Uint8Array(carry.length + value.length);
  bytes.set(carry); bytes.set(value, carry.length);
  const whole = bytes.length - (bytes.length % 2);   // keep a half sample for the next read
  carry = bytes.slice(whole);
  const pcm = new Int16Array(bytes.buffer, 0, whole / 2);
  const buf = ctx.createBuffer(1, pcm.length, 24000), ch = buf.getChannelData(0);
  for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
  const src = ctx.createBufferSource();
  src.buffer = buf; src.connect(ctx.destination);
  at = Math.max(at, ctx.currentTime); src.start(at); at += buf.duration;
}

Streaming to a browser without an API key

POST/api/v1/tts/stream/ticket
GET/api/v1/tts/stream/play/{ticket}

A web page must never hold your API key. Instead your server asks for a ticket (same body as POST /api/v1/tts/stream, plus an optional ticketSeconds) and hands the returned url to the page, which fetches it. The request is checked when the ticket is issued (voice, text, access), so a mistake comes back to your server as a normal error. Nothing is charged until the ticket is played; it is then charged to the account that issued it, exactly like a stream.

A ticket works once and expires (5 minutes by default, 15 at most). Playing it needs no key and no sign-in, and the audio comes straight from Tenish to the browser: raw 16-bit little-endian mono PCM at 24 kHz, with the same headers as a stream. If every worker is busy the answer is 503 with Retry-After and the same ticket can be tried again; an unknown, expired or already-used ticket is 410 ticket_invalid. A page on another site can read the audio only if its origin is listed under Streaming:AllowedOrigins on this site (* allows any: safe, because the ticket is the secret and no cookies are involved).

// on your server
const t = await (await fetch(base + "/api/v1/tts/stream/ticket", {
  method: "POST", headers: { "X-Api-Key": KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ text: "Hello there.", voiceId: VOICE_ID })
})).json();                                     // { ticket, url, expiresInSeconds, sampleRate: 24000, ... }

// in the page: fetch(t.url) and play it exactly as in the example above (no headers needed)

Dialog: let Tenish write and speak the reply

POST/api/v1/dialog/stream
GET/api/v1/dialog/result/{salt}

You describe who the AI is and what is going on, and Tenish has the AI write the reply and speaks each sentence as it is written, streaming one continuous audio response (raw 16-bit little-endian mono PCM at 24 kHz, as for streams). Nothing waits for the whole reply.

A web page must not hold your API key, so the request is sealed by your own server. On the API Keys & Credits page, Encrypted Client Requests shows your account id and an encryption string. Your server puts { "timestamp": "2026-11-08T12:00:00Z", "request": { ... } } in a JSON document and encrypts it with that string and a fresh random salt (AES, the key made from the string and the salt with PBKDF2; the same routine Gig Game's CryptoHelper uses). It gives the page { "accountKey": "<account id>", "salt": "<salt>", "encryptedRequest": "<base64>" }, and the page posts that to /api/v1/dialog/stream with no key and no sign-in.

Only a holder of the string can make a request Tenish accepts, so the request cannot be made or changed by the page. Each sealed request works once and only for a short time after its timestamp (10 minutes by default). The salt must be random and URL-safe (letters, digits, - and _, 8 to 64 characters). Any problem with the sealed request itself, whatever it is, is the same 401.

The request has context (text blocks you write: who the AI is, the situation, the rules), conversation (role host, system or anything else for a person, player for a label, text), optional commands (code, description, isDefault) that the AI may choose from, optional voices (id, name, personality, voiceId) when several voices share the speaking, and options (voiceId for a single voice, model, temperature, maxTurns, allowSilence, allowNoCommand, hostLabel, speakerLabel, systemLabel). Everything is clipped to fixed limits.

Your server reads what the turn came to with GET /api/v1/dialog/result/{salt} and your API key: the lines spoken, the command the AI chose (always one you listed, or your default, or empty), the audio length and the credits charged. It waits for the turn to finish, up to wait seconds (default 20, at most 60), and answers 202 { "ready": false } if it is not done. The speech is charged by the audio actually delivered, plus a small fixed fee for the writing. A reply of silence is an empty 204.

Output settings

Set on the request, on a line, or as a voice's defaults. The most specific wins.

bitrate64, 96, 128 (default), 192, 256 or 320 kbps.
pan-1 (left) to 1 (right). Default 0 (center, mono).
spatialRealism0 to 1 (default 1). With realism, the far ear hears the voice slightly later and duller, like a real head; 0 is a plain volume pan.
volumeDb-24 to 12.
roomTonetrue adds a faint background hum. roomToneLevelDbfs sets its level (-90 to -30, default -66).

Voices

GET/api/v1/voices?scope=system|mine|all&groupId=&q=
GET/api/v1/voices/{id}
PUT/api/v1/voices/{id} name, groupId / clearGroup, defaultFilterId / clearFilter, defaults
DELETE/api/v1/voices/{id}

Each voice has an id, a sampleUrl to listen to, and a status (ready, pending while it is being prepared, or failed).

Voice from a prompt

POST/api/v1/voices/design
{ "description": "A warm, gravelly older man, slow and thoughtful.", "accent": "british", "candidates": 3 }

accent is auto (read it from the description), default or an id from GET /api/v1/meta. Optional sampleText (60 to 400 characters). The finished job lists candidates with preview urls. Keep one:

POST/api/v1/voices/design/{jobId}/save
{ "candidateIndex": 1, "name": "Captain Hollis", "groupId": null, "filterId": null }

The filterId is stored with the voice and not used while it is created. The response includes a jobId for preparing the voice; poll the voice until its status is ready.

Clone a voice

POST/api/v1/uploads multipart form, field "file"
POST/api/v1/voices/cleanup-preview
POST/api/v1/voices/clone
{
  "name": "Jess", "uploadKey": "KEY_FROM_UPLOAD", "consent": true,
  "cleanup": { "highPassHz": 80, "bassDb": 0, "midDb": 1.5, "trebleDb": 1, "denoiseDb": 10, "gate": false, "normalize": true }
}

The recording should be 5 to 60 seconds of clear speech. consent must be true. cleanup-preview takes the same uploadKey and cleanup and returns a job whose result is the cleaned audio.

Emotion variants

POST/api/v1/voices/{id}/variants
{ "emotions": ["angry", "whisper", "joyful"] }

For voices designed from a prompt: builds a variant for each emotion. Lines with that emotion then speak with the variant.

Groups

GETPOST/api/v1/groups { "name" }
PUTDELETE/api/v1/groups/{id}

Filters

GET/api/v1/filters?scope=system|mine|public|all
POST/api/v1/filters name, description, recipe or dsl
PUTDELETE/api/v1/filters/{id}
POST/api/v1/filters/{id}/copy  POST/api/v1/filters/{id}/share
POST/api/v1/filters/parse  POST/api/v1/filters/to-dsl  POST/api/v1/filters/preview

A filter is a recipe: an ordered chain of effects. Only the effects listed in GET /api/v1/meta exist, each with checked ranges, so a recipe can never run arbitrary code.

{
  "name": "Broken Space Radio",
  "chain": [
    { "type": "highpass", "frequency": 300 },
    { "type": "lowpass", "frequency": 3200 },
    { "type": "distortion", "amount": 0.18 },
    { "type": "bitcrush", "bits": 10 },
    { "type": "delay", "ms": "30 + intensity * 80", "feedback": 0.12 },
    { "type": "noise", "levelDb": -38 }
  ],
  "intensity": 0.7, "wet": 1, "gainDb": 0
}

Any number can be a formula using intensity, wet and your own variables. The same recipe can be written as text:

highpass(300)
lowpass(3200)
distort(.18)
bitcrush(10)
delay.ms = 30 + intensity * 80
noise(-38)

preview applies a recipe or filterId to a voice's sample (voiceId) or to an upload (uploadKey) and returns a job with an MP3.

Sound effects and ambience

POST/api/v1/sounds
{ "kind": "effect", "category": "footsteps", "prompt": "Slow footsteps walking on gravel", "seconds": 4, "variations": 2, "quality": "fast" }

kind is effect (foley and one-shot effects, 1 to 15 s) or ambience (continuous background, 5 to 30 s, with optional "loop": true for a seamless loop). quality is fast or detailed. The categories and example prompts are in GET /api/v1/meta under sound. Effects are trimmed and levelled; filterId and output work as for speech. The finished job lists the new items, which are also saved to your library.

Music and songs

POST/api/v1/music/create
{ "prompt": "Tense cinematic underscore, low strings", "seconds": 30, "quality": "fast", "variations": 2, "instrumental": true }

10 to 120 seconds. Add lyrics for vocals, and optionally bpm, key, timeSignature or a reference song to match. quality is fast (turbo), good (XL turbo), better (XL SFT) or best (XL base); the old names balanced and base still work.

The other song tools work on a song in your library. Add your own with POST /api/v1/assets/upload (multipart file), then pass "source": { "assetId": "..." }. Songs can be up to 4 minutes.

Every music call also takes seed (repeat a result) and advanced: an object of the model's finer settings by name, for example "advanced": { "shift": 2.5, "vocalLanguage": "en" }. GET /api/v1/meta lists them all under music.advanced with their ranges, defaults and the kinds of job each applies to. A setting left out uses the model's own default; an unknown name, a value out of range, or a setting that doesn't belong to that kind of job is refused with bad_setting.

POST/api/v1/music/extend source, extendSeconds (5 to 60), direction (after or before), prompt, lyrics, instrumental

Add lyrics (up to 4000 characters) to have the new part sung; without them it is instrumental.

POST/api/v1/music/repaint source, regionStart, regionEnd (-1 or none for the end), prompt, lyrics, instrumental

Redoes one section of a song (up to 120 seconds) and leaves the rest as it is. Priced by the length of the section.

POST/api/v1/music/restyle source, prompt (the new style), strength (0 to 1), keepVocals

Style conversion. With keepVocals the original singer is separated first, only the music is restyled, and the original vocals are put back on top (experimental). Without it, set instrumental or give lyrics for new vocals.

POST/api/v1/music/layer source, track, prompt

Builds one new stem to fit your song. track is one of drums, bass, guitar, keyboard, strings, synth, brass, woodwinds, percussion, fx, backing_vocals, vocals. The result is the new part on its own, with a previewUrl that plays it over your song.

POST/api/v1/music/stems source, model (standard, best, six), stems, shifts (1 to 5), overlap (0.05 to 0.5)

Splits a song into vocals, drums, bass and other (and guitar and piano with six). Every result has a lossless wavUrl. shifts averages several passes for a slightly cleaner split (each adds the same time again); overlap is how much neighbouring pieces overlap while splitting.

Your library

GET /api/v1/assets?kind=effect,ambience,music,stem, GET/PUT/DELETE /api/v1/assets/{id}. Every sound, song and stem you make or upload lives here.

Songs and stems you own carry an origin: where they came from (upload with the file name, create, extend, restyle, repaint, layer or separate), the song they were made from, and every setting they were made with: description, style and mood words, lyrics, quality, length, tempo, key, seed and the advanced settings. Send the same values back to make another take. Only the owner sees origin.

Every song and stem you own also has a rootSource: site (made by Tenish from a description) or upload (a file you uploaded). It is kept for licensing and never changes down the line: whatever is extended, converted, repainted, layered onto or split from an uploaded song is upload too, with rootName (the file name) and rootAssetId (the uploaded item) saying which file it began as. Uploaded songs and everything made from them can't be shared. Filter your library with GET /api/v1/assets?source=site or source=upload.

Songs made of parts

A song is an ordered stack of parts (intro, verse, pre-chorus, chorus, hook, post-chorus, bridge, instrumental, interlude, build, drop, outro). Each part has its own length, tags, lyrics and style prompt, and one or more tracks: the AI render, stems, a live recording, an uploaded sample. Everything is under /api/v1/songs; the web page is My Music. Long jobs answer 202 { jobId } like every other job.

POST/api/v1/songs/create prompt, seconds (10 to 900), lyrics, instrumental, tags, oneSegment, quality, title, autoRender (no lyrics and not instrumental: the AI writes them)

An AI plans the parts (the same model as AI Assist; a fixed shape is used if it is unavailable), then every part is rendered from the beginning to the end, each following on from the end of the one before it so the song holds together. A chorus that repeats is linked and rendered once. oneSegment makes the whole song one part (up to 120 seconds; 80 for the Better and Best models). Answers { songId, jobId, tokens, plan }. Planning costs what AI Assist costs and is given back if the fixed shape had to be used.

GET/api/v1/songs q, source (site or upload), page, pageSize  GET/api/v1/songs/{id}  PUT  DELETE

The first call also turns your older single-file music into one-part songs (once; the originals stay). A song has bpm, key, timeSignature and quality that every part is rendered with so the parts agree; changing one makes rendered parts need rendering again.

POST/api/v1/songs/{id}/parts  PUT.../parts/{pid}  DELETE  POST.../parts/reorder type, label, seconds (1 to 120), tags, lyrics, stylePrompt, instrumental, seed

Editing what a part sounds like marks it as needing a render; the page renders it again automatically (the song's autoRender). A part under 10 seconds is rendered at 10 and trimmed. POST .../parts/{pid}/link { sourcePartId } makes a part reuse another's audio (a repeated chorus); changing a linked part answers 409 linked until it is unlinked (POST .../unlink, or "unlink": true on the edit), which gives it its own copy of the audio.

PUT.../parts/{pid}/tracks/{tid} name, volumeDb (-60 to 12), muted, offsetSec  DELETE  POST.../parts/{pid}/tracks multipart: file, kind (recording or sample), name, seconds

Recordings and small samples are not whole songs, so adding one never changes where the song came from.

GET.../render/estimate  POST.../render partIds  POST.../mixdown  POST.../recreate quality, newSong, prompt, parts

Rendering is priced by the seconds of audio made (each part counts at least 10). The song's mix (an MP3 and a lossless WAV of the whole song) is built when every part has sound and rebuilt by mixdown after tracks change. GET /api/v1/songs/{id}/download?format=mp3|wav downloads the finished, merged song as one file (409 mix_stale until the mix is current). POST .../recreate (quality) starts a song over: the audio of every section and the mix are deleted and each section is made again from its own words. Sections, words and title stay, and no length is carried over (a section's length is worked out again from its words). Audio you added yourself is never deleted, and a shared or busy song is refused.

POST.../parts/{pid}/stem track, prompt  POST.../convert prompt, strength, keepVocals, quality  POST.../split model, stems

Per-part tools. A stem is added as a new track; a conversion adds a new track and mutes the original (switch it back on to undo); a split adds a track per stem and mutes the original so the part sounds the same.

POST/api/v1/songs/{id}/import-part fromSongId, partIds, index

Copies parts from another of your songs, or from a library song whose creator allowed remixing, into this one.

POST/api/v1/songs/import/analyze uploadKey  POST/api/v1/songs/import uploadKey, title, fileName, slices

Upload a whole song with POST /api/v1/uploads, then import it as one part (no slices) or cut it. analyze estimates the parts (job result: seconds, bpm, beats, peaks, sections) so they can be adjusted before import with slices: [{ start, end, type, label }]. The job result is { songId }.

Every song has a rootSource: site (made by Tenish) or upload (a whole song you uploaded, or parts cut from one). It is kept for licensing and carries through everything made from it. A song rooted in an upload can be shared with named people but never with everyone.

Shared Music

Songs their creators shared with everyone. Publish with PUT /api/v1/songs/{id}/sharing { isPublic: true, license: { use, allowRemix, attributionRequired, attributionText } } (the song must be finished and its mix current). use is listen, personal, commercial or unrestricted; listen only cannot be remixed. Share with named people the same way (addEmails, removeUserIds).

GET/api/v1/library q, license, remix, tag, sort (new or popular), page, pageSize

Answers { items, page, pageSize, total, pages }; each item has the creator, license and a mixUrl to play. GET /api/v1/library/{id} adds the parts, the credit lines to show and the songs it was made from. POST .../play counts a listen, GET .../download?format=mp3|wav (not for listen-only songs).

POST/api/v1/library/{id}/remix partIds, title

Copies the song, or some of its parts, into your music as your own song (only when the creator allowed remixing). The creator's license is recorded as it is on that day and never changes afterwards, even if they change it or take the song down. Your song can never be licensed more widely than the strictest song it was made from, and the credit those songs asked for is carried along (constraints.attribution).

Sharing

Voices, sounds, music and stems you made can be shared with everyone (the community) or with specific people, and unshared at any time. People you share with can listen to it and use it (speak with a voice, or use a song as the source for their own edits), but cannot change, delete or reshare it.

GETPUT/api/v1/voices/{id}/sharing  GETPUT/api/v1/assets/{id}/sharing
{ "isPublic": true, "addEmails": ["friend@example.com"], "removeUserIds": ["..."], "confirmRights": true }

Everything is optional: isPublic turns community sharing on or off, addEmails and removeUserIds change the named list (up to 50 people), and confirmRights is required to share a cloned voice with the community. Emails with no Tenish account come back in notFound. Songs you uploaded, and anything made from them, can't be shared.

GET/api/v1/voices?scope=shared|shared-with-me|community|mine|system  GET/api/v1/assets?scope=shared|shared-with-me|community|mine

Items from other people carry the owner's display name (never their email). Report community items with POST /api/v1/reports (targetType, targetId, reason); several separate reports take an item down for review.

AI Assist

Describe what you want in your own words and GPT-5 writes prompts shaped for the model behind each box: voice descriptions, sample sentences, sound effects, ambience, music styles, lyrics, style conversions, extensions, stem parts and filter code (checked with the real filter parser before you see it). In the app, look for the AI Assist button on any prompt box.

POST/api/v1/assist target, idea, current, context, history
{ "target": "music", "idea": "tense chase through a market", "context": { "length": "90 seconds" } }

Returns options (each with title, text, why) and tips. To refine, send the earlier turns as history ({ role, text }, up to 6) with what to change as idea. GET /api/v1/assist/targets lists the targets: voice, voice-sample, sound-effect, ambience, music, lyrics, music-restyle, music-extend, music-layer, filter. Each request costs aiAssist credits and is refunded if the assistant fails (503 not reachable, 422 no usable answer).

System status

GET/api/v1/status

Whether the worker, each model server and FFmpeg are running, plus the job queue. Each service has a status of online, degraded (slow or reporting a problem), unresponsive (running but not answering), offline or unknown (nothing has reported lately), and overall is operational, degraded or outage (no worker is running). In the app, use Status under your credit balance.

Projects: dialogue with a cast

A project is a script you build up and render as one MP3: a cast of characters and lines, each spoken by one of them. A character holds a voice (and optionally an emotion, a filter and audio settings); change the character's voice and every line they speak changes with it. A line can still set its own voice, emotion, filter, audio settings or pause, which win over the character's (audio settings layer setting by setting).

GET/api/v1/projects  POST/api/v1/projects  GET/api/v1/projects/{id}  PUT/api/v1/projects/{id}  DELETE/api/v1/projects/{id}
{
  "name": "Morning coffee",
  "characters": [
    { "id": "ANNA_ID", "name": "Anna", "voiceId": "VOICE_ID", "emotion": "joyful", "output": { "pan": -0.4 } },
    { "id": "BEN_ID",  "name": "Ben",  "voiceId": "OTHER_VOICE_ID" }
  ],
  "lines": [
    { "text": "Good morning. Did you sleep well?", "characterId": "ANNA_ID" },
    { "text": "Not really.", "characterId": "BEN_ID", "emotion": "sad", "pauseAfterMs": 700 }
  ],
  "output": { "bitrate": 192 },
  "gapMs": 350
}

Send the whole cast and the whole list of lines when you save (anything left out is removed). Give characters an id of your own (a GUID) so lines can refer to them. Names are 1 to 40 characters, different from each other, and a project has at most 30 characters; a line may only name a character in the cast (400 invalid_cast otherwise).

POST/api/v1/projects/{id}/render

Renders the saved project: every line is spoken in its character's voice, and you get one joined MP3 plus a file per line. It returns a job id like POST /api/v1/tts (poll GET /api/v1/jobs/{id}; the joined track is result.finalUrl) and is priced like the equivalent speak request. A line with no text is skipped. 400 cannot_render says why a project cannot be rendered yet (for example a character without a voice), and nothing is charged in that case.

In the app, Text to Speech is this: a script of lines with a speaker badge on each, a cast panel, paste-a-script (NAME: line), and a timeline of the finished track.

Account

GET/api/v1/account  GET/api/v1/account/ledger  GET/api/v1/meta

Keys are managed on the website (create and revoke require a signed-in session, not a key).

Example: JavaScript

const base = "https://YOUR_HOST", headers = { "X-Api-Key": KEY, "Content-Type": "application/json" };
const { jobId } = await (await fetch(base + "/api/v1/tts/speak", {
  method: "POST", headers, body: JSON.stringify({ text: "Hello there.", voiceId: VOICE_ID })
})).json();

let job;
do {
  await new Promise(r => setTimeout(r, 1500));
  job = await (await fetch(base + "/api/v1/jobs/" + jobId, { headers })).json();
} while (job.status === "queued" || job.status === "running");
console.log(job.result.finalUrl);